diff --git a/plugins/kolab_2fa/config.inc.php.dist b/plugins/kolab_2fa/config.inc.php.dist index 003e5826..a601823b 100644 --- a/plugins/kolab_2fa/config.inc.php.dist +++ b/plugins/kolab_2fa/config.inc.php.dist @@ -29,32 +29,74 @@ $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( + 'debug' => false, 'hosts' => array('localhost'), 'port' => 389, - 'bind_dn' => 'uid=kolab-service,ou=Special Users,dc=example,dc=org', + 'bind_dn' => 'uid=kolab-auth-service,ou=Special Users,dc=example,dc=org', 'bind_pass' => 'Welcome2KolabSystems', - 'base_dn' => 'ou=People,dc=example,dc=org', - 'filter' => '(&(objectClass=inetOrgPerson)(mail=%fu))', + 'base_dn' => 'ou=Tokens,dc=example,dc=org', + // filter used to list stored factors for a user + 'filter' => '(&(objectClass=ipaToken)(objectclass=ldapSubEntry)(ipatokenOwner=%fu))', 'scope' => 'sub', - 'debug' => true, + // translates driver properties to LDAP attributes 'fieldmap' => array( - 'active' => 'nsroledn', - '@totp' => 'kolabAuthTOTP', - '@hotp' => 'kolabAuthHOTP', - '@yubikey' => 'kolabAuthYubikey', + 'label' => 'cn', + 'id' => 'ipatokenUniqueID', + 'active' => 'ipatokenDisabled', + 'created' => 'ipatokenNotBefore', + 'userdn' => 'ipatokenOwner', + 'secret' => 'ipatokenOTPkey', + // HOTP attributes + 'counter' => 'ipatokenHOTPcounter', + 'digest' => 'ipatokenOTPalgorithm', + 'digits' => 'ipatokenOTPdigits', ), + // LDAP object classes derived from factor IDs (prefix) + // will be translated into the %c placeholder + 'classmap' => array( + 'totp:' => 'ipatokenTOTP', + 'hotp:' => 'ipatokenHOTP', + '*' => 'ipaToken', + ), + // translates property values into LDAP attribute values and vice versa 'valuemap' => array( - 'nsroledn' => array( - 'totp' => 'cn=totp-user,dc=example,dc=org', - 'hotp' => 'cn=hotp-user,dc=example,dc=org', - 'yubikey' => 'cn=yubikey-user,dc=example,dc=org', + 'active' => array( + false => 'TRUE', + true => 'FALSE', ), ), + // specify non-string data types for properties for implicit conversion + 'attrtypes' => array( + 'created' => 'datetime', + 'counter' => 'integer', + 'digits' => 'integer', + ), + // apply these default values to factor records if not specified by the drivers + 'defaults' => array( + 'active' => false, + // these are required for ipatokenHOTP records and should match the kolab_2fa_hotp parameters + 'digest' => 'sha1', + 'digits' => 6, + ), + // use this LDAP attribute to compose DN values for factor entries + 'rdn' => 'ipatokenUniqueID', + // assign these object classes to new factor entries + 'objectclass' => array( + 'top', + 'ipaToken', + '%c', + 'ldapSubEntry', + ), + // add these roles to the user's LDAP record if key prefix-matches a factor entry + 'user_roles' => array( + 'totp:' => 'cn=totp-user,dc=example,dc=org', + 'hotp:' => 'cn=hotp-user,dc=example,dc=org', + ), ); -// force a two-factor authentication for all users +// force a lookup for active authentication factors for this user. // to be set by another plugin (e.g. kolab_auth based on LDAP roles) -// $config['kolab_2fa_factors'] = array('totp'); +// $config['kolab_2fa_check'] = true; // timeout for 2nd factor auth submission (in seconds) $config['kolab_2fa_timeout'] = 60; diff --git a/plugins/kolab_2fa/kolab_2fa.php b/plugins/kolab_2fa/kolab_2fa.php index 4ee7e8d8..0d33bf2d 100644 --- a/plugins/kolab_2fa/kolab_2fa.php +++ b/plugins/kolab_2fa/kolab_2fa.php @@ -97,16 +97,17 @@ class kolab_2fa extends rcube_plugin } // 2a. let plugins provide the list of active authentication factors - $lookup = $rcmail->plugins->exec_hook('kolab_2fa_factors', array( - 'user' => $args['user'], - 'host' => $hostname, - 'active' => $rcmail->config->get('kolab_2fa_factors'), + $lookup = $rcmail->plugins->exec_hook('kolab_2fa_lookup', array( + 'user' => $args['user'], + 'host' => $hostname, + 'factors' => $rcmail->config->get('kolab_2fa_factors'), + 'check' => $rcmail->config->get('kolab_2fa_check', true), )); - if (isset($lookup['active'])) { - $factors = (array)$lookup['active']; + if (isset($lookup['factors'])) { + $factors = (array)$lookup['factors']; } // 2b. check storage if this user has 2FA enabled - else if ($storage = $this->get_storage($args['user'])) { + else if ($lookup['check'] !== false && ($storage = $this->get_storage($args['user']))) { $factors = (array)$storage->enumerate(); } @@ -304,7 +305,6 @@ class kolab_2fa extends rcube_plugin // attach storage $driver->storage = $this->get_storage(); - // set user properties from active session if ($rcmail->user->ID) { $driver->username = $rcmail->get_user_name(); } @@ -334,13 +334,18 @@ class kolab_2fa extends rcube_plugin { if (!isset($this->storage) || (!empty($for) && $this->storage->username !== $for)) { $rcmail = rcmail::get_instance(); - try { $this->storage = \Kolab2FA\Storage\Base::factory( $rcmail->config->get('kolab_2fa_storage', 'roundcube'), $rcmail->config->get('kolab_2fa_storage_config', array()) ); + $this->storage->set_username($for); + + // set user properties from active session + if (!empty($_SESSION['kolab_dn'])) { + $this->storage->userdn = $_SESSION['kolab_dn']; + } } catch (Exception $e) { $this->storage = false; diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php index 89488231..b6a7080a 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php @@ -33,6 +33,7 @@ abstract class Base protected $props = array(); protected $user_props = array(); protected $pending_changes = false; + protected $temporary = false; protected $allowed_props = array('username'); @@ -91,6 +92,7 @@ abstract class Base } else { // generate random ID $this->id = $this->method . ':' . bin2hex(openssl_random_pseudo_bytes(12)); + $this->temporary = true; } } @@ -257,6 +259,7 @@ abstract class Base if (!empty($this->user_props) && $this->storage && $this->pending_changes) { if ($this->storage->write($this->id, $this->user_props)) { $this->pending_changes = false; + $this->temporary = false; } } @@ -294,7 +297,7 @@ abstract class Base */ protected function get_user_prop($key) { - if (!isset($this->user_props[$key]) && $this->storage && !$this->pending_changes) { + if (!isset($this->user_props[$key]) && $this->storage && !$this->pending_changes && !$this->temporary) { $this->user_props = (array)$this->storage->read($this->id); } @@ -306,18 +309,9 @@ abstract class Base */ protected function set_user_prop($key, $value) { - $success = true; $this->pending_changes |= ($this->user_props[$key] !== $value); $this->user_props[$key] = $value; - -/* - if ($this->user_settings[$key] && $this->storage) { - $props = (array)$this->storage->read($this->id); - $props[$key] = $value; - $success = $this->storage->write($this->id, $props); - } -*/ - return $success; + return true; } /** diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php index 89542242..b51e0052 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php @@ -81,11 +81,19 @@ class HOTP extends Base return false; } - $this->backend->setLabel($this->username)->setSecret($secret)->setCounter($this->get('counter')); - $pass = $this->backend->verify($code, $counter, $this->config['window']); + try { + $this->backend->setLabel($this->username)->setSecret($secret)->setCounter(intval($this->get('counter'))); + $pass = $this->backend->verify($code, $counter, $this->config['window']); - // store incremented counter value - $this->set('counter', $this->backend->getCounter()); + // store incremented counter value + $this->set('counter', $this->backend->getCounter()); + $this->commit(); + } + catch (\Exception $e) { + // LOG: exception + console("VERIFY HOTP: $this->id, " . strval($e)); + $pass = false; + } // console('VERIFY HOTP', $this->username, $secret, $counter, $code, $pass); return $pass; @@ -106,7 +114,7 @@ class HOTP extends Base // TODO: deny call if already active? - $this->backend->setLabel($this->username)->setSecret($this->secret)->setCounter($this->get('counter')); + $this->backend->setLabel($this->username)->setSecret($this->secret)->setCounter(intval($this->get('counter'))); return $this->backend->getProvisioningUri(); } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php index ba9ac8a7..f79f2d57 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/Base.php @@ -89,7 +89,7 @@ abstract class Base abstract public function write($key, $value); /** - * Remove the data stoed for the given key + * Remove the data stored for the given key */ abstract public function remove($key); } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php index c8546bb9..67871adf 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php @@ -27,8 +27,10 @@ use \Net_LDAP3; class LDAP extends Base { + public $userdn; + private $cache = array(); - private $users = array(); + private $ldapcache = array(); private $conn; private $error; @@ -52,20 +54,37 @@ class LDAP extends Base } } + /** + * List/set methods activated for this user + */ + public function enumerate($active = true) + { + $filter = $this->parse_vars($this->config['filter'], '*'); + $base_dn = $this->parse_vars($this->config['base_dn'], '*'); + $scope = $this->config['scope'] ?: 'sub'; + $ids = array(); + + if ($this->ready && ($result = $this->conn->search($base_dn, $filter, $scope, array($this->config['fieldmap']['id'], $this->config['fieldmap']['active'])))) { + foreach ($result as $dn => $entry) { + $rec = $this->field_mapping($dn, Net_LDAP3::normalize_entry($entry, true)); + if (!empty($rec['id']) && ($active === null || $active == $rec['active'])) { + $ids[] = $rec['id']; + } + } + } + + // TODO: cache this in memory + + return $ids; + } + /** * Read data for the given key */ public function read($key) { - if (!isset($this->cache[$key]) && ($rec = $this->get_ldap_record($this->username, $key))) { - $pkey = '@' . $key; - if (!empty($this->config['fieldmap'][$pkey])) { - $rec = @json_decode($rec[$pkey], true); - } - else if ($this->config['fieldmap'][$key]) { - $rec = $rec[$key]; - } - $this->cache[$key] = $rec; + if (!isset($this->cache[$key])) { + $this->cache[$key] = $this->get_ldap_record($this->username, $key); } return $this->cache[$key]; @@ -76,54 +95,83 @@ class LDAP extends Base */ public function write($key, $value) { - if ($rec = $this->get_ldap_record($this->username, $key)) { - $old_attrs = $rec['_raw']; - $new_attrs = $old_attrs; + $success = false; + $ldap_attrs = array(); - // serialize $value into one attribute - $pkey = '@' . $key; - if ($attr = $this->config['fieldmap'][$pkey]) { - $new_attrs[$attr] = $value === null ? '' : json_encode($value); - } - else if ($attr = $this->config['fieldmap'][$key]) { - $new_attrs[$attr] = $this->value_mapping($attr, $value, false); + if (is_array($value)) { + // add some default values + $value += (array)$this->config['defaults'] + array('active' => false, 'username' => $this->username, 'userdn' => $this->userdn); - // special case nsroledn: keep other roles unknown to us - if ($attr == 'nsroledn' && is_array($this->config['valuemap'][$attr])) { - $map = $this->config['valuemap'][$attr]; - $new_attrs[$attr] = array_merge( - $new_attrs[$attr], - array_filter((array)$old_attrs[$attr], function($f) use ($map) { return !in_array($f, $map); }) - ); + foreach ($value as $k => $val) { + if ($attr = $this->config['fieldmap'][$k]) { + $ldap_attrs[$attr] = $this->value_mapping($k, $val, false); } } - else if (is_array($value)) { - foreach ($value as $k => $val) { - if ($attr = $this->config['fieldmap'][$k]) { - $new_attrs[$attr] = $this->value_mapping($attr, $value, false); - } - } - } - - $result = $this->conn->modify_entry($rec['_dn'], $old_attrs, $new_attrs); - - if (!empty($result)) { - $this->cache[$key] = $value; - $this->users = array(); - } - - return !empty($result); + } + else { + // invalid data structure + return false; } - return false; + // update existing record + if ($rec = $this->get_ldap_record($this->username, $key)) { + $old_attrs = $rec['_raw']; + $new_attrs = array_merge($old_attrs, $ldap_attrs); + + $result = $this->conn->modify_entry($rec['_dn'], $old_attrs, $new_attrs); + $success = !empty($result); + } + // insert new record + else if ($this->ready) { + $entry_dn = $this->get_entry_dn($this->username, $key); + + // add object class attribute + $me = $this; + $ldap_attrs['objectclass'] = array_map(function($cls) use ($me, $key) { + return $me->parse_vars($cls, $key); + }, (array)$this->config['objectclass']); + + $success = $this->conn->add_entry($entry_dn, $ldap_attrs); + } + + if ($success) { + $this->cache[$key] = $value; + $this->ldapcache = array(); + + // cleanup: remove disabled/inactive/temporary entries + if ($value['active']) { + foreach ($this->enumerate(false) as $id) { + if ($id != $key) { + $this->remove($id); + } + } + + // set user roles according to active factors + $this->set_user_roles(); + } + } + + return $success; } /** - * Remove the data stoed for the given key + * Remove the data stored for the given key */ public function remove($key) { - return $this->write($key, null); + if ($this->ready) { + $entry_dn = $this->get_entry_dn($this->username, $key); + $success = $this->conn->delete_entry($entry_dn); + + // set user roles according to active factors + if ($success) { + $this->set_user_roles(); + } + + return $success; + } + + return false; } /** @@ -135,7 +183,41 @@ class LDAP extends Base // reset cached values $this->cache = array(); - $this->users = array(); + $this->ldapcache = array(); + } + + /** + * + */ + protected function set_user_roles() + { + if (!$this->ready || !$this->userdn || empty($this->config['user_roles'])) { + return false; + } + + $auth_roles = array(); + foreach ($this->enumerate(true) as $id) { + foreach ($this->config['user_roles'] as $prefix => $role) { + if (strpos($id, $prefix) === 0) { + $auth_roles[] = $role; + } + } + } + + $role_attr = $this->config['fieldmap']['roles'] ?: 'nsroledn'; + if ($user_attrs = $this->conn->get_entry($this->userdn, array($role_attr))) { + $internals = array_values($this->config['user_roles']); + $new_attrs = $old_attrs = Net_LDAP3::normalize_entry($user_attrs); + $new_attrs[$role_attr] = array_merge( + array_unique($auth_roles), + array_filter((array)$old_attrs[$role_attr], function($f) use ($internals) { return !in_array($f, $internals); }) + ); + + $result = $this->conn->modify_entry($this->userdn, $old_attrs, $new_attrs); + return !empty($result); + } + + return false; } /** @@ -143,25 +225,26 @@ class LDAP extends Base */ protected function get_ldap_record($user, $key) { - $filter = $this->parse_vars($this->config['filter'], $user, $key); - $base_dn = $this->parse_vars($this->config['base_dn'], $user, $key); - $scope = $this->config['scope'] ?: 'sub'; + $entry_dn = $this->get_entry_dn($user, $key); - $cachekey = $base_dn . $filter; - if (!isset($this->users[$cachekey])) { - $this->users[$cachekey] = array(); + if (!isset($this->ldapcache[$entry_dn])) { + $this->ldapcache[$entry_dn] = array(); - 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); - $this->users[$cachekey] = $this->field_mapping($dn, $entry); - } + if ($this->ready && ($entry = $this->conn->get_entry($entry_dn, array_values($this->config['fieldmap'])))) { + $this->ldapcache[$entry_dn] = $this->field_mapping($entry_dn, Net_LDAP3::normalize_entry($entry, true)); } } - return $this->users[$cachekey]; + return $this->ldapcache[$entry_dn]; + } + + /** + * Compose a full DN for the given record identifier + */ + protected function get_entry_dn($user, $key) + { + $base_dn = $this->parse_vars($this->config['base_dn'], $key); + return sprintf('%s=%s,%s', $this->config['rdn'], Net_LDAP3::quote_string($key, true), $base_dn); } /** @@ -176,10 +259,10 @@ class LDAP extends Base foreach ($this->config['fieldmap'] as $field => $attr) { $attr_lc = strtolower($attr); if (isset($entry[$attr_lc])) { - $entry[$field] = $this->value_mapping($attr_lc, $entry[$attr_lc], true); + $entry[$field] = $this->value_mapping($field, $entry[$attr_lc], true); } else if (isset($entry[$attr])) { - $entry[$field] = $this->value_mapping($attr, $entry[$attr], true); + $entry[$field] = $this->value_mapping($field, $entry[$attr], true); } } @@ -206,28 +289,58 @@ class LDAP extends Base } } + // convert (date) type + switch ($this->config['attrtypes'][$attr]) { + case 'datetime': + $ts = is_numeric($value) ? $value : strtotime($value); + if ($ts) { + $value = gmdate($reverse ? 'U' : 'YmdHi\Z', $ts); + } + break; + + case 'integer': + $value = intval($value); + break; + } + return $value; } /** * Prepares filter query for LDAP search */ - protected function parse_vars($str, $user, $key) + protected function parse_vars($str, $key) { - // replace variables in filter - list($u, $d) = explode('@', $user); + $user = $this->username; + + if (strpos($user, '@') > 0) { + list($u, $d) = explode('@', $user); + } + else if ($this->userdn) { + $u = $this->userdn; + $d = trim(str_replace(',dc=', '.', substr($u, strpos($u, ',dc='))), '.'); + } + + if ($this->userdn) { + $user = $this->userdn; + } // build hierarchal domain string $dc = $this->conn->domain_root_dn($d); - // map key value - if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) { - $key = $this->config['keymap'][$key]; + $class = $this->config['classmap'] ? $this->config['classmap']['*'] : '*'; + + // map key to objectclass + if (is_array($this->config['classmap'])) { + foreach ($this->config['classmap'] as $k => $c) { + if (strpos($key, $k) === 0) { + $class = $c; + break; + } + } } - // TODO: resolve $user into its DN for %udn - - $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $user, '%u' => $u, '%k' => $key); + $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $user, '%u' => $u, '%c' => $class); return strtr($str, $replaces); } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php index f0664bb1..5f614704 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php @@ -116,7 +116,7 @@ class RcubeUser extends Base } /** - * Remove the data stoed for the given key + * Remove the data stored for the given key */ public function remove($key) {