From 358ac3e33f4a3baeb52a8e035e408754c7089761 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Tue, 9 Jun 2015 09:58:32 +0200 Subject: [PATCH] Implement LDAP storage backend for 2-factor-auth (T421). Allow kolab_auth plugin to populate 'kolab_2fa_factors' config option through 'kolab_auth_role_settings' and hereby define the active authentication factors from the one LDAP query. --- plugins/kolab_2fa/config.inc.php.dist | 20 ++- plugins/kolab_2fa/kolab_2fa.php | 27 +++- .../kolab_2fa/lib/Kolab2FA/Driver/Base.php | 2 +- .../kolab_2fa/lib/Kolab2FA/Driver/HOTP.php | 6 +- .../kolab_2fa/lib/Kolab2FA/Driver/TOTP.php | 6 +- .../kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php | 5 - .../kolab_2fa/lib/Kolab2FA/Storage/LDAP.php | 148 ++++++++++++------ .../lib/Kolab2FA/Storage/RcubeUser.php | 25 ++- 8 files changed, 162 insertions(+), 77 deletions(-) diff --git a/plugins/kolab_2fa/config.inc.php.dist b/plugins/kolab_2fa/config.inc.php.dist index 7349421b..003e5826 100644 --- a/plugins/kolab_2fa/config.inc.php.dist +++ b/plugins/kolab_2fa/config.inc.php.dist @@ -20,7 +20,7 @@ */ // available methods/providers. Supported methods are: 'totp','hotp','yubikey' -$config['kolab_2fa_drivers'] = array('totp','hotp'); +$config['kolab_2fa_drivers'] = array('totp'); // backend for storing 2-factor-auth related per-user settings // available backends are: 'roundcube', 'ldap', 'sql' @@ -36,14 +36,20 @@ $config['kolab_2fa_storage_config'] = array( 'base_dn' => 'ou=People,dc=example,dc=org', 'filter' => '(&(objectClass=inetOrgPerson)(mail=%fu))', 'scope' => 'sub', + 'debug' => true, 'fieldmap' => array( - 'uid' => 'uid', - 'mail' => 'mail', - 'totp' => 'kolabAuthTOTP', - 'hotp' => 'kolabAuthHOTP', - 'yubikey' => 'kolabAuthYubikey', + 'active' => 'nsroledn', + '@totp' => 'kolabAuthTOTP', + '@hotp' => 'kolabAuthHOTP', + '@yubikey' => 'kolabAuthYubikey', + ), + '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', + ), ), - 'debug' => true, ); // force a two-factor authentication for all users diff --git a/plugins/kolab_2fa/kolab_2fa.php b/plugins/kolab_2fa/kolab_2fa.php index 97758c68..43c0397a 100644 --- a/plugins/kolab_2fa/kolab_2fa.php +++ b/plugins/kolab_2fa/kolab_2fa.php @@ -96,13 +96,27 @@ class kolab_2fa extends rcube_plugin $rcmail->config->set_user_prefs($user->get_prefs()); } - // 2. check if this user/system has 2FA enabled - if (($storage = $this->get_storage($args['user'])) && count($factors = (array)$storage->read('active')) > 0) { - $args['abort'] = true; + // 2a. let plugins provide the list of active authentication factors + $lookup = $rcmail->plugins->exec_hook('kolab_2fa_factors', array( + 'user' => $args['user'], + 'host' => $hostname, + 'active' => $rcmail->config->get('kolab_2fa_factors'), + )); + if (isset($lookup['active'])) { + $factors = (array)$lookup['active']; + } + // 2b. check storage if this user has 2FA enabled + else if ($storage = $this->get_storage($args['user'])) { + $factors = (array)$storage->read('active'); + } - // 3. flag session as temporary (no further actions allowed) - $_SESSION['kolab_2fa_time'] = time(); - $_SESSION['kolab_2fa_nonce'] = bin2hex(openssl_random_pseudo_bytes(32)); + if (count($factors) > 0) { + $args['abort'] = true; + $factors = array_unique($factors); + + // 3. flag session for 2nd factor verification + $_SESSION['kolab_2fa_time'] = time(); + $_SESSION['kolab_2fa_nonce'] = bin2hex(openssl_random_pseudo_bytes(32)); $_SESSION['kolab_2fa_factors'] = $factors; $_SESSION['username'] = $args['user']; @@ -594,7 +608,6 @@ class kolab_2fa extends rcube_plugin } if (!in_array($method, $active)) { - $driver->set('active', true); $active[] = $method; } } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php index 4e8d28a3..77bd2240 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Base.php @@ -226,7 +226,7 @@ abstract class Base public function clear() { if ($this->storage) { - $this->storage->remove($this->username . ':' . $this->method); + $this->storage->remove($this->method); } } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php index b7805fe4..dc1e0a25 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/HOTP.php @@ -47,11 +47,6 @@ class HOTP extends Base 'label' => 'created', 'generator' => 'time', ), - 'active' => array( - 'type' => 'boolean', - 'editable' => false, - 'hidden' => true, - ), 'counter' => array( 'type' => 'integer', 'editable' => false, @@ -89,6 +84,7 @@ class HOTP extends Base if (!strlen($secret)) { // LOG: "no secret set for user $this->username" + console("VERIFY HOTP: no secret set for user $this->username"); return false; } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php index d4270326..12771afb 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/TOTP.php @@ -47,11 +47,6 @@ class TOTP extends Base 'label' => 'created', 'generator' => 'time', ), - 'active' => array( - 'type' => 'boolean', - 'editable' => false, - 'hidden' => true, - ), ); protected $backend; @@ -83,6 +78,7 @@ class TOTP extends Base if (!strlen($secret)) { // LOG: "no secret set for user $this->username" + console("VERIFY TOTP: no secret set for user $this->username"); return false; } diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php index 72924ebf..15156896 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Driver/Yubikey.php @@ -46,11 +46,6 @@ class Yubikey extends Base 'label' => 'created', 'generator' => 'time', ), - 'active' => array( - 'type' => 'boolean', - 'editable' => false, - 'hidden' => true, - ), ); protected $backend; diff --git a/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php b/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php index ac88b026..c8546bb9 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/LDAP.php @@ -28,6 +28,7 @@ use \Net_LDAP3; class LDAP extends Base { private $cache = array(); + private $users = array(); private $conn; private $error; @@ -56,19 +57,15 @@ class LDAP extends Base */ 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); + 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); } - $this->cache[$key] = $data; + else if ($this->config['fieldmap'][$key]) { + $rec = $rec[$key]; + } + $this->cache[$key] = $rec; } return $this->cache[$key]; @@ -79,19 +76,45 @@ class LDAP extends Base */ public function write($key, $value) { - list($username, $method) = $this->split_key($key); + if ($rec = $this->get_ldap_record($this->username, $key)) { + $old_attrs = $rec['_raw']; + $new_attrs = $old_attrs; + + // 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); + + // 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); }) + ); + } + } + 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(); + } - 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; } @@ -104,35 +127,41 @@ class LDAP extends Base } /** - * Helper method to split the storage key into username and auth-method + * Set username to store data for */ - private function split_key($key) + public function set_username($username) { - return explode(':', $key, 2); + parent::set_username($username); + + // reset cached values + $this->cache = array(); + $this->users = array(); } /** * Fetches user data from LDAP addressbook */ - function get_user_record($user) + protected function get_ldap_record($user, $key) { - $filter = $this->parse_vars($this->config['filter'], $user); - $base_dn = $this->parse_vars($this->config['base_dn'], $user); + $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'; - // 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); + $cachekey = $base_dn . $filter; + if (!isset($this->users[$cachekey])) { + $this->users[$cachekey] = array(); - return $entry; + 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); + } } } - return null; + return $this->users[$cachekey]; } /** @@ -140,36 +169,65 @@ class LDAP extends Base */ protected function field_mapping($dn, $entry) { - $entry['dn'] = $dn; + $entry['_dn'] = $dn; + $entry['_raw'] = $entry; // fields mapping foreach ($this->config['fieldmap'] as $field => $attr) { $attr_lc = strtolower($attr); if (isset($entry[$attr_lc])) { - $entry[$field] = $entry[$attr_lc]; + $entry[$field] = $this->value_mapping($attr_lc, $entry[$attr_lc], true); } else if (isset($entry[$attr])) { - $entry[$field] = $entry[$attr]; + $entry[$field] = $this->value_mapping($attr, $entry[$attr], true); } } return $entry; } + /** + * + */ + protected function value_mapping($attr, $value, $reverse = false) + { + if ($map = $this->config['valuemap'][$attr]) { + if ($reverse) { + $map = array_flip($map); + } + + if (is_array($value)) { + $value = array_filter(array_map(function($val) use ($map) { + return $map[$val]; + }, $value)); + } + else { + $value = $map[$value]; + } + } + + return $value; + } + /** * Prepares filter query for LDAP search */ - protected function parse_vars($str, $user) + protected function parse_vars($str, $user, $key) { // replace variables in filter list($u, $d) = explode('@', $user); - // hierarchal domain string - if (empty($dc)) { - $dc = 'dc=' . strtr($d, array('.' => ',dc=')); + // 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]; } - $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $user, '%u' => $u); + // TODO: resolve $user into its DN for %udn + + $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $user, '%u' => $u, '%k' => $key); 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 80bb4ce9..cdf47393 100644 --- a/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php +++ b/plugins/kolab_2fa/lib/Kolab2FA/Storage/RcubeUser.php @@ -28,6 +28,13 @@ use \rcube_user; class RcubeUser extends Base { + // sefault config + protected $config = array( + 'keymap' => array( + 'active' => 'kolab_2fa_factors', + ), + ); + private $cache = array(); private $user; @@ -46,7 +53,7 @@ class RcubeUser extends Base { if (!isset($this->cache[$key]) && ($user = $this->get_user($this->username))) { $prefs = $user->get_prefs(); - $pkey = 'kolab_2fa_props_' . $key; + $pkey = $this->key2property($key); $this->cache[$key] = $prefs[$pkey]; } @@ -60,7 +67,7 @@ class RcubeUser extends Base { if ($user = $this->get_user($this->username)) { $this->cache[$key] = $value; - $pkey = 'kolab_2fa_props_' . $key; + $pkey = $this->key2property($key); return $user->save_prefs(array($pkey => $value), true); } @@ -105,4 +112,18 @@ class RcubeUser extends Base return $this->user; } + /** + * + */ + private function key2property($key) + { + // map key to configured property name + if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) { + return $this->config['keymap'][$key]; + } + + // default + return 'kolab_2fa_props_' . $key; + } + }