Refactor the 2FA plugin/drivers/storage to allow multiple factors of the same type

This commit is contained in:
Thomas Bruederli 2015-06-10 18:20:08 +02:00
parent 358ac3e33f
commit 7f3a76fdad
9 changed files with 275 additions and 165 deletions

View file

@ -45,29 +45,28 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
table.html(''); table.html('');
var rows = 0; var rows = 0;
$.each(rcmail.env.kolab_2fa_factors, function(method, props) { $.each(rcmail.env.kolab_2fa_factors, function(id, props) {
if (props.active) { if (props.active) {
var tr = $('<tr>').addClass(method).appendTo(table); var tr = $('<tr>').addClass(props.method).appendTo(table);
$('<td>').addClass('name').text(props.name || method).appendTo(tr); $('<td>').addClass('name').text(props.label || props.name).appendTo(tr);
$('<td>').addClass('created').text(props.created || '??').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); $('<td>').addClass('actions').html('<a class="button delete" rel="'+id+'">' + rcmail.get_label('remove','kolab_2fa') + '</a>').appendTo(tr);
rows++; rows++;
} }
}); });
table.parent()[(rows > 0 ? 'show' : 'hide')](); table.parent()[(rows > 0 ? 'show' : 'hide')]();
/*
var remaining = 0; var remaining = 0;
$('#kolab2fa-add option').each(function(i, elem) { $('#kolab2fa-add option').each(function(i, elem) {
var method = elem.value; var method = elem.value;
if (rcmail.env.kolab_2fa_factors[method]) { $(elem).prop('disabled', active[method]);
$(elem).prop('disabled', rcmail.env.kolab_2fa_factors[method].active); if (!active[method]) {
if (!rcmail.env.kolab_2fa_factors[method].active) { remaining++;
remaining++;
}
} }
}); });
$('#kolab2fa-add').prop('disabled', !remaining).get(0).selectedIndex = 0; $('#kolab2fa-add').prop('disabled', !remaining).get(0).selectedIndex = 0;
*/
} }
/** /**
@ -102,7 +101,11 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
], ],
{ {
open: function(event, ui) { open: function(event, ui) {
$(event.target).find('input[name="_verify_code"]').keypress(function(e) {
if (e.which == 13) {
$(e.target).closest('.ui-dialog').find('.ui-button.mainaction').click();
}
});
}, },
close: function(event, ui) { close: function(event, ui) {
form.hide().appendTo(document.body); form.hide().appendTo(document.body);
@ -128,21 +131,21 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
/** /**
* Remove the given factor from the account * Remove the given factor from the account
*/ */
function remove_factor(method) { function remove_factor(id) {
if (rcmail.env.kolab_2fa_factors[method]) { if (rcmail.env.kolab_2fa_factors[id]) {
rcmail.env.kolab_2fa_factors[method].active = false; rcmail.env.kolab_2fa_factors[id].active = false;
} }
render(); render();
var lock = rcmail.set_busy(true, 'saving'); var lock = rcmail.set_busy(true, 'saving');
rcmail.http_post('plugin.kolab-2fa-save', { _method: method, _data: 'false' }, lock); rcmail.http_post('plugin.kolab-2fa-save', { _method: id, _data: 'false' }, lock);
} }
/** /**
* Submit factor settings form * Submit factor settings form
*/ */
function save_data(method) { function save_data(method) {
var lock, form = $('#kolab2fa-prop-' + method), var lock, data, form = $('#kolab2fa-prop-' + method),
verify = form.find('input[name="_verify_code"]'); verify = form.find('input[name="_verify_code"]');
if (verify.length && !verify.val().length) { if (verify.length && !verify.val().length) {
@ -151,10 +154,11 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
return false; return false;
} }
data = form_data(form);
lock = rcmail.set_busy(true, 'saving'); lock = rcmail.set_busy(true, 'saving');
rcmail.http_post('plugin.kolab-2fa-save', { rcmail.http_post('plugin.kolab-2fa-save', {
_method: method, _method: data.id || method,
_data: JSON.stringify(form_data(form)), _data: JSON.stringify(data),
_verify_code: verify.val(), _verify_code: verify.val(),
_timestamp: factor_dialog ? factor_dialog.data('timestamp') : null _timestamp: factor_dialog ? factor_dialog.data('timestamp') : null
}, lock); }, lock);
@ -181,18 +185,20 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
/** /**
* Execute the given function after the user authorized the session with a 2nd factor * Execute the given function after the user authorized the session with a 2nd factor
*/ */
function require_high_security(func) function require_high_security(func, exclude)
{ {
// request 2nd factor auth // request 2nd factor auth
if (!rcmail.env.session_secured || rcmail.env.session_secured < time() - 120) { if (!rcmail.env.session_secured || rcmail.env.session_secured < time() - 120) {
var method, name; var method, name;
// find an active factor // find an active factor
$.each(rcmail.env.kolab_2fa_factors, function(m, prop) { $.each(rcmail.env.kolab_2fa_factors, function(id, prop) {
if (prop.active) { if (prop.active && !method || method == exclude) {
method = m; method = id;
name = prop.name; name = prop.label || prop.name;
return true; if (!exclude || id !== exclude) {
return true;
}
} }
}); });
@ -259,12 +265,12 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
// callback for factor data provided by the server // callback for factor data provided by the server
rcmail.addEventListener('plugin.render_data', function(data) { rcmail.addEventListener('plugin.render_data', function(data) {
var method = data._method, var method = data.method,
form = $('#kolab2fa-prop-' + method); form = $('#kolab2fa-prop-' + method);
if (form.length) { if (form.length) {
$.each(data, function(field, value) { $.each(data, function(field, value) {
form.find('[name="_prop[' + method + '][' + field + ']"]').val(value); form.find('[name="_prop[' + field + ']"]').val(value);
}); });
if (data.qrcode) { if (data.qrcode) {
@ -278,8 +284,14 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
// callback for save action // callback for save action
rcmail.addEventListener('plugin.save_success', function(data) { rcmail.addEventListener('plugin.save_success', function(data) {
if (rcmail.env.kolab_2fa_factors[data.method]) { if (!data.active && rcmail.env.kolab_2fa_factors[data.id]) {
$.extend(rcmail.env.kolab_2fa_factors[data.method], data); delete rcmail.env.kolab_2fa_factors[data.id];
}
else if (rcmail.env.kolab_2fa_factors[data.id]) {
$.extend(rcmail.env.kolab_2fa_factors[data.id], data);
}
else {
rcmail.env.kolab_2fa_factors[data.id] = data;
} }
if (factor_dialog) { if (factor_dialog) {
@ -334,14 +346,14 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
// handler for delete button clicks // handler for delete button clicks
$('#kolab2fa-factors tbody').on('click', '.button.delete', function(e) { $('#kolab2fa-factors tbody').on('click', '.button.delete', function(e) {
var method = $(this).attr('rel'); var id = $(this).attr('rel');
// require auth verification // require auth verification
require_high_security(function() { require_high_security(function() {
if (confirm(rcmail.get_label('authremoveconfirm', 'kolab_2fa'))) { if (confirm(rcmail.get_label('authremoveconfirm', 'kolab_2fa'))) {
remove_factor(method); remove_factor(id);
} }
}); }, id);
return false; return false;
}); });

View file

@ -107,7 +107,7 @@ class kolab_2fa extends rcube_plugin
} }
// 2b. check storage if this user has 2FA enabled // 2b. check storage if this user has 2FA enabled
else if ($storage = $this->get_storage($args['user'])) { else if ($storage = $this->get_storage($args['user'])) {
$factors = (array)$storage->read('active'); $factors = (array)$storage->enumerate();
} }
if (count($factors) > 0) { if (count($factors) > 0) {
@ -155,19 +155,20 @@ class kolab_2fa extends rcube_plugin
$time = $_SESSION['kolab_2fa_time']; $time = $_SESSION['kolab_2fa_time'];
$nonce = $_SESSION['kolab_2fa_nonce']; $nonce = $_SESSION['kolab_2fa_nonce'];
$factors = (array)$_SESSION['kolab_2fa_factors']; $factors = (array)$_SESSION['kolab_2fa_factors'];
$sign = rcube_utils::get_input_value('_sign', rcube_utils::INPUT_POST);
$this->login_verified = false; $this->login_verified = false;
$expired = $time < time() - $rcmail->config->get('kolab_2fa_timeout', 120); $expired = $time < time() - $rcmail->config->get('kolab_2fa_timeout', 120);
if (!empty($sign) && !empty($factors) && !empty($nonce) && !$expired) { if (!empty($factors) && !empty($nonce) && !$expired) {
// TODO: check signature // TODO: check signature
// try to verify each configured factor // try to verify each configured factor
foreach ($factors as $method) { foreach ($factors as $factor) {
list($method) = explode(':', $factor, 2);
// verify the submitted code // verify the submitted code
$code = rcube_utils::get_input_value("_${nonce}_${method}", rcube_utils::INPUT_POST); $code = rcube_utils::get_input_value("_${nonce}_${method}", rcube_utils::INPUT_POST);
$this->login_verified = $this->verify_factor_auth($method, $code); $this->login_verified = $this->verify_factor_auth($factor, $code);
// accept first successful method // accept first successful method
if ($this->login_verified) { if ($this->login_verified) {
@ -225,21 +226,26 @@ class kolab_2fa extends rcube_plugin
$form_name = !empty($attrib['form']) ? $attrib['form'] : 'form'; $form_name = !empty($attrib['form']) ? $attrib['form'] : 'form';
$nonce = $_SESSION['kolab_2fa_nonce']; $nonce = $_SESSION['kolab_2fa_nonce'];
$methods = array_unique(array_map(function($factor) {
list($method, $id) = explode(':', $factor);
return $method;
},
$this->login_factors
));
// forward these values as the regular login screen would submit them // forward these values as the regular login screen would submit them
$input_task = new html_hiddenfield(array('name' => '_task', 'value' => 'login')); $input_task = new html_hiddenfield(array('name' => '_task', 'value' => 'login'));
$input_action = new html_hiddenfield(array('name' => '_action', 'value' => 'plugin.kolab-2fa-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_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))); $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 // create HTML table with two cols
$table = new html_table(array('cols' => 2)); $table = new html_table(array('cols' => 2));
$required = count($this->login_factors) > 1 ? null : 'required'; $required = count($methods) > 1 ? null : 'required';
// render input for each configured auth method // render input for each configured auth method
foreach ($this->login_factors as $i => $method) { foreach ($methods as $i => $method) {
if ($i > 0) { if ($row++ > 0) {
$table->add(array('colspan' => 2, 'class' => 'title hint', 'style' => 'text-align:center'), $table->add(array('colspan' => 2, 'class' => 'title hint', 'style' => 'text-align:center'),
$this->gettext('or')); $this->gettext('or'));
} }
@ -255,7 +261,6 @@ class kolab_2fa extends rcube_plugin
$out .= $input_action->show(); $out .= $input_action->show();
$out .= $input_tzone->show(); $out .= $input_tzone->show();
$out .= $input_url->show(); $out .= $input_url->show();
$out .= $input_sign->show();
$out .= $table->show(); $out .= $table->show();
// add submit button // add submit button
@ -279,12 +284,6 @@ class kolab_2fa extends rcube_plugin
public function get_driver($method) public function get_driver($method)
{ {
$rcmail = rcmail::get_instance(); $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]) { if ($this->drivers[$method]) {
return $this->drivers[$method]; return $this->drivers[$method];
@ -404,13 +403,11 @@ class kolab_2fa extends rcube_plugin
public function settings_factoradder($attrib) public function settings_factoradder($attrib)
{ {
$rcmail = rcmail::get_instance(); $rcmail = rcmail::get_instance();
$storage = $this->get_storage($rcmail->get_user_name());
$active = $storage ? (array)$storage->read('active') : array();
$select = new html_select(array('id' => 'kolab2fa-add')); $select = new html_select(array('id' => 'kolab2fa-add'));
$select->add($this->gettext('addfactor') . '...', ''); $select->add($this->gettext('addfactor') . '...', '');
foreach ((array)$rcmail->config->get('kolab_2fa_drivers', array()) as $method) { foreach ((array)$rcmail->config->get('kolab_2fa_drivers', array()) as $method) {
$select->add($this->gettext($method), $method, array('disabled' => in_array($method, $active))); $select->add($this->gettext($method), $method);
} }
return $select->show(); return $select->show();
@ -438,30 +435,37 @@ class kolab_2fa extends rcube_plugin
{ {
$rcmail = rcmail::get_instance(); $rcmail = rcmail::get_instance();
$storage = $this->get_storage($rcmail->get_user_name()); $storage = $this->get_storage($rcmail->get_user_name());
$factors = $storage ? (array)$storage->read('active') : array(); $factors = $storage ? (array)$storage->enumerate() : array();
$drivers = (array)$rcmail->config->get('kolab_2fa_drivers', array()); $drivers = (array)$rcmail->config->get('kolab_2fa_drivers', array());
$env_methods = array();
foreach ($drivers as $j => $method) { foreach ($drivers as $j => $method) {
$out .= $this->settings_factor($method, $attrib); $out .= $this->settings_factor($method, $attrib);
$env_methods[$method] = array(
'name' => $this->gettext($method),
'active' => 0,
);
} }
$me = $this; $me = $this;
$this->api->output->set_env('kolab_2fa_factors', array_combine( $this->api->output->set_env('kolab_2fa_factors', array_combine(
$drivers, $factors,
array_map(function($method) use ($me, $factors) { array_map(function($id) use ($me, &$env_methods) {
$props = array( $props = array('id' => $id);
'name' => $me->gettext($method),
'active' => in_array($method, $factors),
);
if ($props['active'] && ($driver = $me->get_driver($method))) { if ($driver = $me->get_driver($id)) {
$props += $this->format_props($driver->props()); $props += $this->format_props($driver->props());
$props['method'] = $driver->method;
$props['name'] = $me->gettext($driver->method);
$env_methods[$driver->method]['active']++;
} }
return $props; return $props;
}, $drivers) }, $factors)
)); ));
$this->api->output->set_env('kolab_2fa_methods', $env_methods);
return html::div(array('id' => 'kolab2fapropform'), $out); return html::div(array('id' => 'kolab2fapropform'), $out);
} }
@ -528,6 +532,8 @@ class kolab_2fa extends rcube_plugin
} }
$input_id = new html_hiddenfield(array('name' => '_prop[id]', 'value' => ''));
$out .= html::tag('form', array( $out .= html::tag('form', array(
'method' => 'post', 'method' => 'post',
'action' => '#', 'action' => '#',
@ -536,7 +542,8 @@ class kolab_2fa extends rcube_plugin
), ),
html::tag('fieldset', array(), html::tag('fieldset', array(),
html::tag('legend', array(), $this->gettext($method)) . html::tag('legend', array(), $this->gettext($method)) .
html::div('factorprop', $table->show()) html::div('factorprop', $table->show()) .
$input_id->show()
) )
); );
} }
@ -566,11 +573,10 @@ class kolab_2fa extends rcube_plugin
public function settings_save() public function settings_save()
{ {
$method = rcube_utils::get_input_value('_method', rcube_utils::INPUT_POST); $method = rcube_utils::get_input_value('_method', rcube_utils::INPUT_POST);
$data = @json_decode(rcube_utils::get_input_value('_data', rcube_utils::INPUT_POST), true); $data = @json_decode(rcube_utils::get_input_value('_data', rcube_utils::INPUT_POST), true);
$rcmail = rcmail::get_instance(); $rcmail = rcmail::get_instance();
$storage = $this->get_storage($rcmail->get_user_name()); $storage = $this->get_storage($rcmail->get_user_name());
$active = $storage ? (array)$storage->read('active') : array();
$success = false; $success = false;
$errors = 0; $errors = 0;
$save_data = array(); $save_data = array();
@ -579,8 +585,7 @@ class kolab_2fa extends rcube_plugin
if ($data === false) { if ($data === false) {
if ($this->check_secure_mode()) { if ($this->check_secure_mode()) {
// remove method from active factors and clear stored settings // remove method from active factors and clear stored settings
$active = array_filter($active, function($f) use ($method) { return $f != $method; }); $success = $driver->clear();
$driver->clear();
} }
else { else {
$errors++; $errors++;
@ -593,9 +598,10 @@ class kolab_2fa extends rcube_plugin
if (!empty($verify_code)) { if (!empty($verify_code)) {
if (!$driver->verify($verify_code, $timestamp)) { if (!$driver->verify($verify_code, $timestamp)) {
$this->api->output->command('plugin.verify_response', array( $this->api->output->command('plugin.verify_response', array(
'method' => $method, 'id' => $driver->id,
'method' => $driver->method,
'success' => false, 'success' => false,
'message' => str_replace('$method', $this->gettext($method), $this->gettext('codeverificationfailed')) 'message' => str_replace('$method', $this->gettext($driver->method), $this->gettext('codeverificationfailed'))
)); ));
$this->api->output->send(); $this->api->output->send();
} }
@ -607,14 +613,12 @@ class kolab_2fa extends rcube_plugin
} }
} }
if (!in_array($method, $active)) { $driver->set('active', true);
$active[] = $method;
}
} }
// update list of active factors for this user // update list of active factors for this user
if (!$errors) { if (!$errors) {
$success = $storage && $storage->write('active', $active); $success = $driver->commit();
$save_data = $data !== false ? $this->format_props($driver->props()) : array(); $save_data = $data !== false ? $this->format_props($driver->props()) : array();
} }
} }
@ -639,14 +643,7 @@ class kolab_2fa extends rcube_plugin
$method = rcube_utils::get_input_value('_method', rcube_utils::INPUT_POST); $method = rcube_utils::get_input_value('_method', rcube_utils::INPUT_POST);
if ($driver = $this->get_driver($method)) { if ($driver = $this->get_driver($method)) {
$data = array('_method' => $method); $data = array('method' => $method, 'id' => $driver->id);
// abort if session is not authorized
/*
if ($driver->active && !$this->check_secure_mode()) {
$this->api->output->send();
}
*/
foreach ($driver->props(true) as $field => $prop) { foreach ($driver->props(true) as $field => $prop) {
$data[$field] = $prop['text'] ?: $prop['value']; $data[$field] = $prop['text'] ?: $prop['value'];
@ -696,6 +693,7 @@ class kolab_2fa extends rcube_plugin
} }
} }
$success = $driver->verify(rcube_utils::get_input_value('_code', rcube_utils::INPUT_POST), $timestamp); $success = $driver->verify(rcube_utils::get_input_value('_code', rcube_utils::INPUT_POST), $timestamp);
$method = $driver->method;
} }
// put session into high-security mode // put session into high-security mode
@ -726,10 +724,6 @@ class kolab_2fa extends rcube_plugin
$value = $rcmail->format_date($prop['value']); $value = $rcmail->format_date($prop['value']);
break; break;
case 'boolean':
$value = $this->gettext($prop['value'] ? 'yes' : 'no');
break;
default: default:
$value = $prop['value']; $value = $prop['value'];
} }

View file

@ -26,21 +26,45 @@ namespace Kolab2FA\Driver;
abstract class Base abstract class Base
{ {
public $method = null; public $method = null;
public $id = null;
public $storage; public $storage;
protected $config = array(); protected $config = array();
protected $props = array(); protected $props = array();
protected $user_props = array(); protected $user_props = array();
protected $pending_changes = false;
protected $allowed_props = array('username'); protected $allowed_props = array('username');
public $user_settings = array(); public $user_settings = array(
'active' => array(
'type' => 'boolean',
'editable' => false,
'hidden' => false,
'default' => false,
),
'label' => array(
'type' => 'text',
'editable' => true,
'label' => 'label',
'generator' => 'default_label',
),
'created' => array(
'type' => 'datetime',
'editable' => false,
'hidden' => false,
'label' => 'created',
'generator' => 'time',
),
);
/** /**
* Static factory method * Static factory method
*/ */
public static function factory($method, $config) public static function factory($id, $config)
{ {
list($method) = explode(':', $id);
$classmap = array( $classmap = array(
'totp' => '\\Kolab2FA\\Driver\\TOTP', 'totp' => '\\Kolab2FA\\Driver\\TOTP',
'hotp' => '\\Kolab2FA\\Driver\\HOTP', 'hotp' => '\\Kolab2FA\\Driver\\HOTP',
@ -49,7 +73,7 @@ abstract class Base
$cls = $classmap[strtolower($method)]; $cls = $classmap[strtolower($method)];
if ($cls && class_exists($cls)) { if ($cls && class_exists($cls)) {
return new $cls($config); return new $cls($config, $id);
} }
throw new Exception("Unknown 2FA driver '$method'"); throw new Exception("Unknown 2FA driver '$method'");
@ -58,19 +82,26 @@ abstract class Base
/** /**
* Default constructor * Default constructor
*/ */
public function __construct($config = null) public function __construct($config = null, $id = null)
{ {
if (is_array($config)) { $this->init($config);
$this->init($config);
if (!empty($id) && $id != $this->method) {
$this->id = $id;
}
else { // generate random ID
$this->id = $this->method . ':' . bin2hex(openssl_random_pseudo_bytes(12));
} }
} }
/** /**
* Initialize the driver with the given config options * Initialize the driver with the given config options
*/ */
public function init(array $config) public function init($config)
{ {
$this->config = array_merge($this->config, $config); if (is_array($config)) {
$this->config = array_merge($this->config, $config);
}
if ($config['storage']) { if ($config['storage']) {
$this->storage = \Kolab2FA\Storage\Base::factory($config['storage'], $config['storage_config']); $this->storage = \Kolab2FA\Storage\Base::factory($config['storage'], $config['storage_config']);
@ -152,6 +183,18 @@ abstract class Base
return $secret; return $secret;
} }
/**
* Generate the default label based on the method
*/
public function default_label()
{
if (class_exists('\\rcmail', false)) {
return \rcmail::get_instance()->gettext($this->method, 'kolab_2fa');
}
return strtoupper($this->method);
}
/** /**
* Getter for read-only access to driver properties * Getter for read-only access to driver properties
*/ */
@ -170,7 +213,7 @@ abstract class Base
if (is_callable($func)) { if (is_callable($func)) {
$value = call_user_func($func); $value = call_user_func($func);
} }
if (!isset($value)) { if (isset($value)) {
$this->set_user_prop($key, $value); $this->set_user_prop($key, $value);
} }
} }
@ -206,6 +249,20 @@ abstract class Base
return true; return true;
} }
/**
* Commit changes to storage
*/
public function commit()
{
if (!empty($this->user_props) && $this->storage && $this->pending_changes) {
if ($this->storage->write($this->id, $this->user_props)) {
$this->pending_changes = false;
}
}
return !$this->pending_changes;
}
/** /**
* Dedicated setter for the username property * Dedicated setter for the username property
*/ */
@ -226,8 +283,10 @@ abstract class Base
public function clear() public function clear()
{ {
if ($this->storage) { if ($this->storage) {
$this->storage->remove($this->method); return $this->storage->remove($this->id);
} }
return false;
} }
/** /**
@ -235,8 +294,8 @@ abstract class Base
*/ */
protected function get_user_prop($key) protected function get_user_prop($key)
{ {
if (!isset($this->user_props[$key]) && $this->storage) { if (!isset($this->user_props[$key]) && $this->storage && !$this->pending_changes) {
$this->user_props = (array)$this->storage->read($this->method); $this->user_props = (array)$this->storage->read($this->id);
} }
return $this->user_props[$key]; return $this->user_props[$key];
@ -247,15 +306,17 @@ abstract class Base
*/ */
protected function set_user_prop($key, $value) protected function set_user_prop($key, $value)
{ {
$success = false; $success = true;
$this->pending_changes |= ($this->user_props[$key] !== $value);
$this->user_props[$key] = $value; $this->user_props[$key] = $value;
/*
if ($this->user_settings[$key] && $this->storage) { if ($this->user_settings[$key] && $this->storage) {
$props = (array)$this->storage->read($this->method); $props = (array)$this->storage->read($this->id);
$props[$key] = $value; $props[$key] = $value;
$success = $this->storage->write($this->method, $props); $success = $this->storage->write($this->id, $props);
} }
*/
return $success; return $success;
} }

View file

@ -33,37 +33,30 @@ class HOTP extends Base
'digest' => 'sha1', 'digest' => 'sha1',
); );
public $user_settings = array(
'secret' => array(
'type' => 'text',
'private' => true,
'label' => 'secret',
'generator' => 'generate_secret',
),
'created' => array(
'type' => 'datetime',
'editable' => false,
'hidden' => false,
'label' => 'created',
'generator' => 'time',
),
'counter' => array(
'type' => 'integer',
'editable' => false,
'hidden' => true,
'generator' => 'random_counter',
),
);
protected $backend; protected $backend;
/** /**
* *
*/ */
public function init(array $config) public function init($config)
{ {
parent::init($config); parent::init($config);
$this->user_settings += array(
'secret' => array(
'type' => 'text',
'private' => true,
'label' => 'secret',
'generator' => 'generate_secret',
),
'counter' => array(
'type' => 'integer',
'editable' => false,
'hidden' => true,
'generator' => 'random_counter',
),
);
// copy config options // copy config options
$this->backend = new \Kolab2FA\OTP\HOTP(); $this->backend = new \Kolab2FA\OTP\HOTP();
$this->backend $this->backend
@ -108,6 +101,7 @@ class HOTP extends Base
$this->set('secret', $this->get('secret', true)); $this->set('secret', $this->get('secret', true));
$this->set('counter', $this->get('counter', true)); $this->set('counter', $this->get('counter', true));
$this->set('created', $this->get('created', true)); $this->set('created', $this->get('created', true));
$this->commit();
} }
// TODO: deny call if already active? // TODO: deny call if already active?

View file

@ -33,31 +33,24 @@ class TOTP extends Base
'digest' => 'sha1', 'digest' => 'sha1',
); );
public $user_settings = array(
'secret' => array(
'type' => 'text',
'private' => true,
'label' => 'secret',
'generator' => 'generate_secret',
),
'created' => array(
'type' => 'datetime',
'editable' => false,
'hidden' => false,
'label' => 'created',
'generator' => 'time',
),
);
protected $backend; protected $backend;
/** /**
* *
*/ */
public function init(array $config) public function init($config)
{ {
parent::init($config); parent::init($config);
$this->user_settings += array(
'secret' => array(
'type' => 'text',
'private' => true,
'label' => 'secret',
'generator' => 'generate_secret',
),
);
// copy config options // copy config options
$this->backend = new \Kolab2FA\OTP\TOTP(); $this->backend = new \Kolab2FA\OTP\TOTP();
$this->backend $this->backend
@ -103,10 +96,13 @@ class TOTP extends Base
*/ */
public function get_provisioning_uri() public function get_provisioning_uri()
{ {
console('PROV', $this->secret);
if (!$this->secret) { if (!$this->secret) {
// generate new secret and store it // generate new secret and store it
$this->set('secret', $this->get('secret', true)); $this->set('secret', $this->get('secret', true));
$this->set('created', $this->get('created', true)); $this->set('created', $this->get('created', true));
console('PROV2', $this->secret);
$this->commit();
} }
// TODO: deny call if already active? // TODO: deny call if already active?

View file

@ -33,21 +33,6 @@ class Yubikey extends Base
'hosts' => null, 'hosts' => null,
); );
public $user_settings = array(
'yubikeyid' => array(
'type' => 'text',
'editable' => true,
'label' => 'secret',
),
'created' => array(
'type' => 'datetime',
'editable' => false,
'hidden' => false,
'label' => 'created',
'generator' => 'time',
),
);
protected $backend; protected $backend;
/** /**
@ -57,6 +42,14 @@ class Yubikey extends Base
{ {
parent::init($config); parent::init($config);
$this->user_settings += array(
'yubikeyid' => array(
'type' => 'text',
'editable' => true,
'label' => 'secret',
),
);
// initialize validator // initialize validator
$this->backend = new \Yubikey\Validate($this->config['apikey'], $this->config['clientid']); $this->backend = new \Yubikey\Validate($this->config['apikey'], $this->config['clientid']);

View file

@ -73,6 +73,11 @@ abstract class Base
$this->username = $username; $this->username = $username;
} }
/**
* List keys holding settings for 2-factor-authentication
*/
abstract public function enumerate();
/** /**
* Read data for the given key * Read data for the given key
*/ */

View file

@ -30,9 +30,7 @@ class RcubeUser extends Base
{ {
// sefault config // sefault config
protected $config = array( protected $config = array(
'keymap' => array( 'keymap' => array(),
'active' => 'kolab_2fa_factors',
),
); );
private $cache = array(); private $cache = array();
@ -46,15 +44,29 @@ class RcubeUser extends Base
$this->config['hostname'] = $rcmail->user->ID ? $rcmail->user->data['mail_host'] : $_SESSION['hostname']; $this->config['hostname'] = $rcmail->user->ID ? $rcmail->user->data['mail_host'] : $_SESSION['hostname'];
} }
/**
* List/set methods activated for this user
*/
public function enumerate()
{
if ($factors = $this->get_factors()) {
return array_keys(array_filter($factors, function($prop) {
return !empty($prop['active']);
}));
}
return array();
}
/** /**
* Read data for the given key * Read data for the given key
*/ */
public function read($key) public function read($key)
{ {
if (!isset($this->cache[$key]) && ($user = $this->get_user($this->username))) { if (!isset($this->cache[$key])) {
$prefs = $user->get_prefs(); $factors = $this->get_factors();
$pkey = $this->key2property($key); console('READ', $key, $factors);
$this->cache[$key] = $prefs[$pkey]; $this->cache[$key] = $factors[$key];
} }
return $this->cache[$key]; return $this->cache[$key];
@ -67,8 +79,37 @@ class RcubeUser extends Base
{ {
if ($user = $this->get_user($this->username)) { if ($user = $this->get_user($this->username)) {
$this->cache[$key] = $value; $this->cache[$key] = $value;
$pkey = $this->key2property($key);
return $user->save_prefs(array($pkey => $value), true); $factors = $this->get_factors();
$factors[$key] = $value;
$pkey = $this->key2property('blob');
$save_data = array($pkey => $factors);
$update_index = false;
// remove entry
if ($value === null) {
unset($factors[$key]);
$update_index = true;
}
// remove non-active entries
else if (!empty($value['active'])) {
$factors = array_filter($factors, function($prop) {
return !empty($prop['active']);
});
$update_index = true;
}
// update the index of active factors
if ($update_index) {
$save_data[$this->key2property('factors')] = array_keys(
array_filter($factors, function($prop) {
return !empty($prop['active']);
})
);
}
return $user->save_prefs($save_data, true);
} }
return false; return false;
@ -112,6 +153,19 @@ class RcubeUser extends Base
return $this->user; return $this->user;
} }
/**
*
*/
private function get_factors()
{
if ($user = $this->get_user($this->username)) {
$prefs = $user->get_prefs();
return (array)$prefs[$this->key2property('blob')];
}
return null;
}
/** /**
* *
*/ */
@ -123,7 +177,7 @@ class RcubeUser extends Base
} }
// default // default
return 'kolab_2fa_props_' . $key; return 'kolab_2fa_' . $key;
} }
} }

View file

@ -23,8 +23,9 @@ $labels['yubikey'] = 'Yubikey';
$labels['or'] = 'or'; $labels['or'] = 'or';
$labels['yes'] = 'yes'; $labels['yes'] = 'yes';
$labels['now'] = 'no'; $labels['no'] = 'no';
$labels['label'] = 'Name';
$labels['qrcode'] = 'QR Code'; $labels['qrcode'] = 'QR Code';
$labels['showqrcode'] = 'Show 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/> $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/>