diff --git a/bin/phpstan b/bin/phpstan --- a/bin/phpstan +++ b/bin/phpstan @@ -4,7 +4,7 @@ pushd ${cwd}/../src/ -php -dmemory_limit=256M \ +php -dmemory_limit=320M \ vendor/bin/phpstan \ analyse diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -23,6 +23,11 @@ SESSION_DRIVER=file SESSION_LIFETIME=120 +2FA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube +2FA_TOTP_DIGITS=6 +2FA_TOTP_INTERVAL=30 +2FA_TOTP_DIGEST=sha1 + IMAP_URI=ssl://127.0.0.1:993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php new file mode 100644 --- /dev/null +++ b/src/app/Auth/SecondFactor.php @@ -0,0 +1,335 @@ + [], + ]; + + + /** + * Class constructor + * + * @param \App\User $user User object + */ + public function __construct($user) + { + $this->user = $user; + + parent::__construct(); + } + + /** + * Validate 2-factor authentication code + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse|null + */ + public function requestHandler($request) + { + // get list of configured authentication factors + $factors = $this->factors(); + + // do nothing if no factors configured + if (empty($factors)) { + return null; + } + + if (empty($request->secondfactor) || !is_string($request->secondfactor)) { + $errors = ['secondfactor' => \trans('validation.2fareq')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + // try to verify each configured factor + foreach ($factors as $factor) { + // verify the submitted code + // if (strpos($factor, 'dummy:') === 0 && (\app('env') != 'production') { + // if ($request->secondfactor === 'dummy') { + // return null; + // } + // } else + if ($this->verify($factor, $request->secondfactor)) { + return null; + } + } + + $errors = ['secondfactor' => \trans('validation.2fainvalid')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + /** + * Remove all configured 2FA methods for the current user + * + * @return bool True on success, False otherwise + */ + public function removeFactors(): bool + { + $this->cache = []; + + $prefs = []; + $prefs[$this->key2property('blob')] = null; + $prefs[$this->key2property('factors')] = null; + + return $this->savePrefs($prefs); + } + + /** + * Returns a list of 2nd factor methods configured for the user + */ + public function factors(): array + { + // First check if the user has the 2FA SKU + $sku_2fa = Sku::where('title', '2fa')->first(); + + if ($sku_2fa) { + $has_2fa = $this->user->entitlements()->where('sku_id', $sku_2fa->id)->first(); + + if ($has_2fa) { + $factors = (array) $this->enumerate(); + $factors = array_unique($factors); + + return $factors; + } + } + + return []; + } + + /** + * Helper method to verify the given method/code tuple + * + * @param string $factor Factor identifier (:) + * @param string $code Authentication code + * + * @return bool True on successful validation + */ + protected function verify($factor, $code): bool + { + if ($driver = $this->getDriver($factor)) { + return $driver->verify($code, time()); + } + + return false; + } + + /** + * Load driver class for the given authentication factor + * + * @param string $factor Factor identifier (:) + * + * @return \Kolab2FA\Driver\Base + */ + protected function getDriver(string $factor) + { + list($method) = explode(':', $factor, 2); + + $config = \config('2fa.' . $method, []); + + $driver = \Kolab2FA\Driver\Base::factory($factor, $config); + + // configure driver + $driver->storage = $this; + $driver->username = $this->user->email; + + return $driver; + } + + /** + * Helper for seeding a Roundcube account with 2FA setup + * for testing. + * + * @param string $email Email address + */ + public static function seed(string $email): void + { + $config = [ + 'kolab_2fa_blob' => [ + 'totp:8132a46b1f741f88de25f47e' => [ + 'label' => 'Mobile app (TOTP)', + 'created' => 1584573552, + 'secret' => 'UAF477LDHZNWVLNA', + 'active' => true, + ], + // 'dummy:dummy' => [ + // 'active' => true, + // ], + ], + 'kolab_2fa_factors' => [ + 'totp:8132a46b1f741f88de25f47e', + // 'dummy:dummy', + ] + ]; + + self::dbh()->table('users')->updateOrInsert( + ['username' => $email, 'mail_host' => '127.0.0.1'], + ['preferences' => serialize($config)] + ); + } + + /** + * Helper for generating current TOTP code for a test user + * + * @param string $email Email address + * + * @return string Generated code + */ + public static function code(string $email): string + { + $sf = new self(User::where('email', $email)->first()); + $driver = $sf->getDriver('totp:8132a46b1f741f88de25f47e'); + + return (string) $driver->get_code(); + } + + + //****************************************************** + // Methods required by Kolab2FA Storage Base + //****************************************************** + + /** + * Initialize the storage driver with the given config options + */ + public function init(array $config) + { + $this->config = array_merge($this->config, $config); + } + + /** + * List methods activated for this user + */ + public function enumerate() + { + if ($factors = $this->getFactors()) { + return array_keys(array_filter($factors, function ($prop) { + return !empty($prop['active']); + })); + } + + return []; + } + + /** + * Read data for the given key + */ + public function read($key) + { + \Log::debug(__METHOD__ . ' ' . $key); + + if (!isset($this->cache[$key])) { + $factors = $this->getFactors(); + $this->cache[$key] = $factors[$key]; + } + + return $this->cache[$key]; + } + + /** + * Save data for the given key + */ + public function write($key, $value) + { + \Log::debug(__METHOD__ . ' ' . @json_encode($value)); + + // TODO: Not implemented + return false; + } + + /** + * Remove the data stored for the given key + */ + public function remove($key) + { + return $this->write($key, null); + } + + /** + * + */ + protected function getFactors(): array + { + $prefs = $this->getPrefs(); + $key = $this->key2property('blob'); + + return isset($prefs[$key]) ? (array) $prefs[$key] : []; + } + + /** + * + */ + protected 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_' . $key; + } + + /** + * Gets user preferences from Roundcube users table + */ + protected function getPrefs() + { + $user = $this->dbh()->table('users') + ->select('preferences') + ->where('username', strtolower($this->user->email)) + ->first(); + + return $user ? (array) unserialize($user->preferences) : null; + } + + /** + * Saves user preferences in Roundcube users table. + * This will merge into old preferences + */ + protected function savePrefs($prefs) + { + $old_prefs = $this->getPrefs(); + + if (!is_array($old_prefs)) { + return false; + } + + $prefs = array_merge($old_prefs, $prefs); + + $this->dbh()->table('users') + ->where('username', strtolower($this->user->email)) + ->update(['preferences' => serialize($prefs)]); + + return true; + } + + /** + * Init connection to the Roundcube database + */ + public static function dbh() + { + $dsn = \config('2fa.dsn'); + + if (empty($dsn)) { + \Log::warning("2-FACTOR database not configured"); + + return DB::connection(\config('database.default')); + } + + \Config::set('database.connections.2fa', ['url' => $dsn]); + + return DB::connection('2fa'); + } +} diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php --- a/src/app/Http/Controllers/API/UsersController.php +++ b/src/app/Http/Controllers/API/UsersController.php @@ -131,6 +131,12 @@ $credentials = $request->only('email', 'password'); if ($token = $this->guard()->attempt($credentials)) { + $sf = new \App\Auth\SecondFactor($this->guard()->user()); + + if ($response = $sf->requestHandler($request)) { + return $response; + } + return $this->respondWithToken($token); } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -53,4 +53,21 @@ return false; } } + + /** + * Handle the entitlement "deleted" event. + * + * @param \App\Entitlement $entitlement The entitlement. + * + * @return void + */ + public function deleted(Entitlement $entitlement) + { + // Remove all configured 2FA methods from Roundcube database + if ($entitlement->sku->title == '2fa') { + // FIXME: Should that be an async job? + $sf = new \App\Auth\SecondFactor($entitlement->entitleable); + $sf->removeFactors(); + } + } } diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -26,6 +26,7 @@ "morrislaptop/laravel-queue-clear": "^1.2", "silviolleite/laravelpwa": "^1.0", "spatie/laravel-translatable": "^4.2", + "spomky-labs/otphp": "~4.0.0", "swooletw/laravel-swoole": "^2.6", "torann/currency": "^1.0", "torann/geoip": "^1.0", diff --git a/src/config/2fa.php b/src/config/2fa.php new file mode 100644 --- /dev/null +++ b/src/config/2fa.php @@ -0,0 +1,14 @@ + [ + 'digits' => (int) env('2FA_TOTP_DIGITS', 6), + 'interval' => (int) env('2FA_TOTP_INTERVAL', 30), + 'digest' => env('2FA_TOTP_DIGEST', 'sha1'), + 'issuer' => env('APP_NAME', 'Laravel'), + ], + + 'dsn' => env('2FA_DSN'), + +]; diff --git a/src/config/database.php b/src/config/database.php --- a/src/config/database.php +++ b/src/config/database.php @@ -90,7 +90,6 @@ 'prefix' => '', 'prefix_indexes' => true, ], - ], /* diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php --- a/src/database/seeds/UserSeeder.php +++ b/src/database/seeds/UserSeeder.php @@ -1,5 +1,6 @@ wallets()->first()->addController($ned); + // Ned is also our 2FA test user + $sku2fa = Sku::firstOrCreate(['title' => '2fa']); + $ned->assignSku($sku2fa); + SecondFactor::seed('ned@kolab.org'); + factory(User::class, 10)->create(); } } diff --git a/src/include/Kolab2FA/Driver/Base.php b/src/include/Kolab2FA/Driver/Base.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/Driver/Base.php @@ -0,0 +1,354 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Driver; + +abstract class Base +{ + public $method; + public $id; + public $storage; + public $username; + + protected $config = array(); + protected $props = array(); + protected $user_props = array(); + protected $pending_changes = false; + protected $temporary = false; + protected $allowed_props = array('username'); + + 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 + */ + public static function factory($id, $config) + { + list($method) = explode(':', $id); + + $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, $id); + } + + throw new Exception("Unknown 2FA driver '$method'"); + } + + /** + * Default constructor + */ + public function __construct($config = null, $id = null) + { + $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)); + $this->temporary = true; + } + } + + /** + * Initialize the driver with the given config options + */ + public function init($config) + { + if (is_array($config)) { + $this->config = array_merge($this->config, $config); + } + + if (!empty($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 (!empty($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; + } + + /** + * 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); + } + + /** + * Get current code (for testing) + */ + public function get_code() + { + // to be overriden by a driver + return ''; + } + + /** + * 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_func(array($this, $setter), $value); + } + else if (in_array($key, $this->allowed_props)) { + $this->props[$key] = $value; + } + + 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; + $this->temporary = false; + } + } + + return !$this->pending_changes; + } + + /** + * Dedicated setter for the username property + */ + public function set_username($username) + { + $this->props['username'] = $username; + + if ($this->storage) { + $this->storage->set_username($username); + } + + return true; + } + + /** + * Clear data stored for this driver + */ + public function clear() + { + if ($this->storage) { + return $this->storage->remove($this->id); + } + + return false; + } + + /** + * Getter for per-user properties for this method + */ + protected function get_user_prop($key) + { + if (!isset($this->user_props[$key]) && $this->storage && !$this->pending_changes && !$this->temporary) { + $this->user_props = (array)$this->storage->read($this->id); + } + + return $this->user_props[$key]; + } + + /** + * Setter for per-user properties for this method + */ + protected function set_user_prop($key, $value) + { + $this->pending_changes |= ($this->user_props[$key] !== $value); + $this->user_props[$key] = $value; + return true; + } + + /** + * 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]); + } +} diff --git a/src/include/Kolab2FA/Driver/Exception.php b/src/include/Kolab2FA/Driver/Exception.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/Driver/Exception.php @@ -0,0 +1,8 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Driver; + +class HOTP extends Base +{ + public $method = 'hotp'; + + protected $config = array( + 'digits' => 6, + 'window' => 4, + 'digest' => 'sha1', + ); + + protected $backend; + + /** + * + */ + public function 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 + $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" + // rcube::console("VERIFY HOTP: no secret set for user $this->username"); + return false; + } + + 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()); + $this->commit(); + } + catch (\Exception $e) { + // LOG: exception + // rcube::console("VERIFY HOTP: $this->id, " . strval($e)); + $pass = false; + } + + // rcube::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)); + $this->commit(); + } + + // TODO: deny call if already active? + + $this->backend->setLabel($this->username)->setSecret($this->secret)->setCounter(intval($this->get('counter'))); + return $this->backend->getProvisioningUri(); + } + + /** + * Generate a random counter value + */ + public function random_counter() + { + return mt_rand(1, 999); + } +} diff --git a/src/include/Kolab2FA/Driver/TOTP.php b/src/include/Kolab2FA/Driver/TOTP.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/Driver/TOTP.php @@ -0,0 +1,137 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Driver; + +class TOTP extends Base +{ + public $method = 'totp'; + + protected $config = array( + 'digits' => 6, + 'interval' => 30, + 'digest' => 'sha1', + ); + + protected $backend; + + /** + * + */ + public function init($config) + { + parent::init($config); + + $this->user_settings += array( + 'secret' => array( + 'type' => 'text', + 'private' => true, + 'label' => 'secret', + 'generator' => 'generate_secret', + ), + ); + + // 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" + // rcube::console("VERIFY TOTP: no secret set for user $this->username"); + return false; + } + + $this->backend->setLabel($this->username)->setSecret($secret); + + // PHP gets a string, but we're comparing integers. + $code = (int)$code; +//$code = (string) $code; + // Pass a window to indicate the maximum timeslip between client (mobile + // device) and server. + $pass = $this->backend->verify($code, $timestamp, 150); + + // 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']; + } + } + + // rcube::console('VERIFY TOTP', $this->username, $secret, $code, $timestamp, $pass); + return $pass; + } + + /** + * Get current code (for testing) + */ + public function get_code() + { + // get my secret from the user storage + $secret = $this->get('secret'); + + if (!strlen($secret)) { + return; + } + + $this->backend->setLabel($this->username)->setSecret($secret); + + return $this->backend->at(time()); + } + + /** + * + */ + public function get_provisioning_uri() + { + // rcube::console('PROV', $this->secret); + if (!$this->secret) { + // generate new secret and store it + $this->set('secret', $this->get('secret', true)); + $this->set('created', $this->get('created', true)); + // rcube::console('PROV2', $this->secret); + $this->commit(); + } + + // TODO: deny call if already active? + + $this->backend->setLabel($this->username)->setSecret($this->secret); + return $this->backend->getProvisioningUri(); + } + +} diff --git a/src/include/Kolab2FA/Driver/Yubikey.php b/src/include/Kolab2FA/Driver/Yubikey.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/Driver/Yubikey.php @@ -0,0 +1,126 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Driver; + +class Yubikey extends Base +{ + public $method = 'yubikey'; + + protected $backend; + + /** + * + */ + public function init($config) + { + parent::init($config); + + $this->user_settings += array( + 'yubikeyid' => array( + 'type' => 'text', + 'editable' => true, + 'label' => 'secret', + ), + ); + + // 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']); + } + + if (isset($this->config['use_https'])) { + $this->backend->setUseSecure((bool)$this->config['use_https']); + } + } + + /** + * + */ + 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 + } + } + + // rcube::console('VERIFY Yubikey', $this->username, $keyid, $code, $pass); + return $pass; + } + + /** + * @override + */ + public function set($key, $value) + { + if ($key == 'yubikeyid' && strlen($value) > 12) { + // verify the submitted code + try { + $response = $this->backend->check($value); + if ($response->success() !== true) { + // TODO: report error + return false; + } + } + catch (\Exception $e) { + return false; + } + + // truncate the submitted yubikey code to 12 characters + $value = substr($value, 0, 12); + } + + return parent::set($key, $value); + } + + /** + * @override + */ + protected function set_user_prop($key, $value) + { + // set created timestamp + if ($key !== 'created' && !isset($this->created)) { + parent::set_user_prop('created', $this->get('created', true)); + } + + return parent::set_user_prop($key, $value); + } +} diff --git a/src/include/Kolab2FA/Log/Logger.php b/src/include/Kolab2FA/Log/Logger.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/Log/Logger.php @@ -0,0 +1,42 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Log; + +interface Logger +{ + /** + * Setter for the log name + */ + public function set_name($name); + + /** + * Setter for the minimum log level + */ + public function set_level($level); + + /** + * Do log the given message at the given level + */ + public function log($level, $message); +} diff --git a/src/include/Kolab2FA/Log/RcubeLogger.php b/src/include/Kolab2FA/Log/RcubeLogger.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/Log/RcubeLogger.php @@ -0,0 +1,80 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Log; + +use \rcube; + +class RcubeLogger implements Logger +{ + protected $name = null; + protected $level = LOG_DEBUG; + + public function __construct($name = null) + { + if ($name !== null) { + $this->set_name($name); + } + } + + public function set_name($name) + { + $this->name = $name; + } + + public function set_level($name) + { + $this->level = $level; + } + + public function log($level, $message) + { + if (!is_string($message)) { + $message = var_export($message, true); + } + + switch ($level) { + case LOG_DEBUG: + case LOG_INFO: + case LOG_NOTICE: + if ($level >= $this->level) { + rcube::write_log($this->name ?: 'console', $message); + } + break; + + case LOG_EMERGE: + case LOG_ALERT: + case LOG_CRIT: + case LOG_ERR: + case LOG_WARNING: + rcube::raise_error(array( + 'code' => 600, + 'type' => 'php', + 'message' => $message, + ), true, false); + break; + } + } +} + diff --git a/src/include/Kolab2FA/Log/Syslog.php b/src/include/Kolab2FA/Log/Syslog.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/Log/Syslog.php @@ -0,0 +1,54 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Log; + +use \rcube; + +class Syslog implements Logger +{ + protected $name = 'Kolab2FA'; + protected $level = LOG_INFO; + + public function set_name($name) + { + $this->name = $name; + } + + public function set_level($name) + { + $this->level = $level; + } + + public function log($level, $message) + { + if ($level >= $this->level) { + if (!is_string($message)) { + $message = var_export($message, true); + } + + syslog($level, '[' . $this->name . '] ' . $message); + } + } +} diff --git a/src/include/Kolab2FA/OTP/HOTP.php b/src/include/Kolab2FA/OTP/HOTP.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/OTP/HOTP.php @@ -0,0 +1,58 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +namespace Kolab2FA\OTP; + +use OTPHP\HOTP as Base; + +class HOTP extends Base +{ + use OTP; + protected $counter = 0; + + public function setCounter($counter) + { + if (!is_integer($counter) || $counter < 0) { + throw new \Exception('Counter must be at least 0.'); + } + $this->counter = $counter; + + return $this; + } + + public function getCounter() + { + return $this->counter; + } + + public function updateCounter($counter) + { + $this->counter = $counter; + + return $this; + } +} diff --git a/src/include/Kolab2FA/OTP/OTP.php b/src/include/Kolab2FA/OTP/OTP.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/OTP/OTP.php @@ -0,0 +1,133 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\OTP; + +trait OTP +{ + protected $secret = null; + protected $issuer = null; + protected $issuer_included_as_parameter = false; + protected $label = null; + protected $digest = 'sha1'; + protected $digits = 6; + + public function setSecret($secret) + { + $this->secret = $secret; + + return $this; + } + + public function getSecret() + { + return $this->secret; + } + + public function setLabel($label) + { + if ($this->hasSemicolon($label)) { + throw new \Exception('Label must not contain a semi-colon.'); + } + $this->label = $label; + + return $this; + } + + public function getLabel() + { + return $this->label; + } + + public function setIssuer($issuer) + { + if ($this->hasSemicolon($issuer)) { + throw new \Exception('Issuer must not contain a semi-colon.'); + } + $this->issuer = $issuer; + + return $this; + } + + public function getIssuer() + { + return $this->issuer; + } + + public function isIssuerIncludedAsParameter() + { + return $this->issuer_included_as_parameter; + } + + public function setIssuerIncludedAsParameter($issuer_included_as_parameter) + { + $this->issuer_included_as_parameter = $issuer_included_as_parameter; + + return $this; + } + + public function setDigits($digits) + { + if (!is_numeric($digits) || $digits < 1) { + throw new \Exception('Digits must be at least 1.'); + } + $this->digits = $digits; + + return $this; + } + + public function getDigits() + { + return $this->digits; + } + + public function setDigest($digest) + { + if (!in_array($digest, array('md5', 'sha1', 'sha256', 'sha512'))) { + throw new \Exception("'$digest' digest is not supported."); + } + $this->digest = $digest; + + return $this; + } + + public function getDigest() + { + return $this->digest; + } + + private function hasSemicolon($value) + { + $semicolons = array(':', '%3A', '%3a'); + foreach ($semicolons as $semicolon) { + if (false !== strpos($value, $semicolon)) { + return true; + } + } + + return false; + } +} diff --git a/src/include/Kolab2FA/OTP/TOTP.php b/src/include/Kolab2FA/OTP/TOTP.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/OTP/TOTP.php @@ -0,0 +1,50 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\OTP; + +use OTPHP\TOTP as Base; + +class TOTP extends Base +{ + use OTP; + protected $interval = 30; + + public function setInterval($interval) + { + if (!is_integer($interval) || $interval < 1) { + throw new \Exception('Interval must be at least 1.'); + } + $this->interval = $interval; + + return $this; + } + + public function getInterval() + { + return $this->interval; + } +} diff --git a/src/include/Kolab2FA/Storage/Base.php b/src/include/Kolab2FA/Storage/Base.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/Storage/Base.php @@ -0,0 +1,127 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Storage; + +use \Kolab2FA\Log; + + +abstract class Base +{ + public $username = null; + protected $config = array(); + protected $logger; + + /** + * + */ + 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); + + // use syslog logger by default + $this->set_logger(new Log\Syslog()); + } + + /** + * + */ + public function set_logger(Log\Logger $logger) + { + $this->logger = $logger; + + if (!empty($this->config['debug'])) { + $this->logger->set_level(LOG_DEBUG); + } + else if (isset($this->config['loglevel'])) { + $this->logger->set_level($this->config['loglevel']); + } + } + + /** + * Set username to store data for + */ + public function set_username($username) + { + $this->username = $username; + } + + /** + * Send messager to the logging system + */ + protected function log($level, $message) + { + if ($this->logger) { + $this->logger->log($level, $message); + } + } + + /** + * List keys holding settings for 2-factor-authentication + */ + abstract public function enumerate(); + + /** + * 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 stored for the given key + */ + abstract public function remove($key); +} diff --git a/src/include/Kolab2FA/Storage/Exception.php b/src/include/Kolab2FA/Storage/Exception.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/Storage/Exception.php @@ -0,0 +1,8 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Storage; + +use \Net_LDAP3; +use \Kolab2FA\Log\Logger; + +class LDAP extends Base +{ + public $userdn; + public $ready; + + private $cache = array(); + private $ldapcache = 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); + } + } + + /** + * 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])) { + $this->cache[$key] = $this->get_ldap_record($this->username, $key); + } + + return $this->cache[$key]; + } + + /** + * Save data for the given key + */ + public function write($key, $value) + { + $success = false; + $ldap_attrs = array(); + + if (is_array($value)) { + // add some default values + $value += (array)$this->config['defaults'] + array('active' => false, 'username' => $this->username, 'userdn' => $this->userdn); + + foreach ($value as $k => $val) { + if ($attr = $this->config['fieldmap'][$k]) { + $ldap_attrs[$attr] = $this->value_mapping($k, $val, false); + } + } + } + else { + // invalid data structure + 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 stored for the given key + */ + public function remove($key) + { + 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; + } + + /** + * Set username to store data for + */ + public function set_username($username) + { + parent::set_username($username); + + // reset cached values + $this->cache = 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; + } + + /** + * Fetches user data from LDAP addressbook + */ + protected function get_ldap_record($user, $key) + { + $entry_dn = $this->get_entry_dn($user, $key); + + if (!isset($this->ldapcache[$entry_dn])) { + $this->ldapcache[$entry_dn] = array(); + + 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->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); + } + + /** + * Maps LDAP attributes to defined fields + */ + protected function field_mapping($dn, $entry) + { + $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] = $this->value_mapping($field, $entry[$attr_lc], true); + } + else if (isset($entry[$attr])) { + $entry[$field] = $this->value_mapping($field, $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]; + } + } + + // 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, $key) + { + $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); + + $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; + } + } + } + + $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $user, '%u' => $u, '%c' => $class); + + return strtr($str, $replaces); + } + +} diff --git a/src/include/Kolab2FA/Storage/RcubeUser.php b/src/include/Kolab2FA/Storage/RcubeUser.php new file mode 100644 --- /dev/null +++ b/src/include/Kolab2FA/Storage/RcubeUser.php @@ -0,0 +1,195 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Storage; + +use \rcmail; +use \rcube_user; + +class RcubeUser extends Base +{ + // sefault config + protected $config = array( + 'keymap' => array(), + ); + + private $cache = array(); + private $user; + + 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']; + } + + /** + * 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 + */ + public function read($key) + { + if (!isset($this->cache[$key])) { + $factors = $this->get_factors(); + $this->log(LOG_DEBUG, 'RcubeUser::read() ' . $key); + $this->cache[$key] = $factors[$key]; + } + + return $this->cache[$key]; + } + + /** + * Save data for the given key + */ + public function write($key, $value) + { + $this->log(LOG_DEBUG, 'RcubeUser::write() ' . @json_encode($value)); + + if ($user = $this->get_user($this->username)) { + $this->cache[$key] = $value; + + $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']); + }) + ); + } + + $success = $user->save_prefs($save_data, true); + + if (!$success) { + $this->log(LOG_WARNING, sprintf('Failed to save prefs for user %s', $this->username)); + } + + return $success; + } + + return false; + } + + /** + * Remove the data stored for the given key + */ + public function remove($key) + { + return $this->write($key, null); + } + + /** + * Set username to store data for + */ + public function set_username($username) + { + parent::set_username($username); + + // reset cached values + $this->cache = array(); + $this->user = null; + } + + /** + * 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; + } + + if (!$this->user) { + $this->user = rcube_user::query($username, $this->config['hostname']); + } + + if (!$this->user) { + $this->log(LOG_WARNING, sprintf('No user record found for %s @ %s', $username, $this->config['hostname'])); + } + + 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; + } + + /** + * + */ + 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_' . $key; + } + +} diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -56,9 +56,10 @@ return response }, error => { - var error_msg + let error_msg + let status = error.response ? error.response.status : 200 - if (error.response && error.response.status == 422) { + if (error.response && status == 422) { error_msg = "Form validation error" $.each(error.response.data.errors || {}, (idx, msg) => { diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -11,6 +11,9 @@ faCheck, faGlobe, faInfoCircle, + faLock, + faKey, + faSignInAlt, faSyncAlt, faTrashAlt, faUser, @@ -22,10 +25,13 @@ // Register only these icons we need library.add( faCheckSquare, - faSquare, faCheck, faGlobe, faInfoCircle, + faLock, + faKey, + faSignInAlt, + faSquare, faSyncAlt, faTrashAlt, faUser, diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -16,4 +16,5 @@ 'failed' => 'Invalid username or password.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'logoutsuccess' => 'Successfully logged out.', + ]; diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -117,6 +117,9 @@ 'url' => 'The :attribute format is invalid.', 'uuid' => 'The :attribute must be a valid UUID.', + + '2fareq' => 'Second factor code is required.', + '2fainvalid' => 'Second factor code is invalid.', 'emailinvalid' => 'The specified email address is invalid.', 'domaininvalid' => 'The specified domain is invalid.', 'logininvalid' => 'The specified login is invalid.', diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -1,36 +1,73 @@ + - - diff --git a/src/tests/Browser.php b/src/tests/Browser.php --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -5,6 +5,7 @@ use Facebook\WebDriver\WebDriverKeys; use PHPUnit\Framework\Assert; use Tests\Browser\Components\Error; +use Tests\Browser\Components\Toast; /** * Laravel Dusk Browser extensions @@ -45,6 +46,20 @@ } /** + * Assert Toast element content (and close it) + */ + public function assertToast($type, $title, $message) + { + return $this->withinBody(function ($browser) use ($type, $title, $message) { + $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) { + $browser->assertToastTitle($title) + ->assertToastMessage($message) + ->closeToast(); + }); + }); + } + + /** * Assert specified error page is displayed. */ public function assertErrorPage(int $error_code) diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -156,4 +156,47 @@ }); }); } + + /** + * Test 2-Factor Authentication + * + * @depends testLogoutByURL + */ + public function test2FA(): void + { + $this->browse(function (Browser $browser) { + // Test missing 2fa code + $browser->on(new Home()) + ->type('@email-input', 'ned@kolab.org') + ->type('@password-input', 'simple123') + ->press('form button') + ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') + ->assertSeeIn( + '@second-factor-input.is-invalid + .invalid-feedback', + 'Second factor code is required.' + ) + ->assertFocused('@second-factor-input') + ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + + // Test invalid code + $browser->type('@second-factor-input', '123456') + ->press('form button') + ->waitUntilMissing('@second-factor-input.is-invalid') + ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') + ->assertSeeIn( + '@second-factor-input.is-invalid + .invalid-feedback', + 'Second factor code is invalid.' + ) + ->assertFocused('@second-factor-input') + ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + + $code = \App\Auth\SecondFactor::code('ned@kolab.org'); + + // Test valid (TOTP) code + $browser->type('@second-factor-input', $code) + ->press('form button') + ->waitUntilMissing('@second-factor-input.is-invalid') + ->waitForLocation('/dashboard')->on(new Dashboard()); + }); + } } diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php --- a/src/tests/Browser/Pages/Home.php +++ b/src/tests/Browser/Pages/Home.php @@ -38,6 +38,9 @@ { return [ '@app' => '#app', + '@email-input' => '#inputEmail', + '@password-input' => '#inputPassword', + '@second-factor-input' => '#secondfactor', ]; } @@ -53,10 +56,15 @@ */ public function submitLogon($browser, $username, $password, $wait_for_dashboard = false) { - $browser - ->type('#inputEmail', $username) - ->type('#inputPassword', $password) - ->press('form button'); + $browser->type('@email-input', $username) + ->type('@password-input', $password); + + if ($username == 'ned@kolab.org') { + $code = \App\Auth\SecondFactor::code('ned@kolab.org'); + $browser->type('@second-factor-input', $code); + } + + $browser->press('form button'); if ($wait_for_dashboard) { $browser->waitForLocation('/dashboard'); diff --git a/src/tests/Feature/Auth/SecondFactorTest.php b/src/tests/Feature/Auth/SecondFactorTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Auth/SecondFactorTest.php @@ -0,0 +1,63 @@ +deleteTestUser('entitlement-test@kolabnow.com'); + } + + public function tearDown(): void + { + $this->deleteTestUser('entitlement-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test that 2FA config is removed from Roundcube database + * on entitlement delete + */ + public function testEntitlementDelete(): void + { + // Create the user, and assign 2FA to him, and add Roundcube setup + $sku_2fa = Sku::where('title', '2fa')->first(); + $user = $this->getTestUser('entitlement-test@kolabnow.com'); + $user->assignSku($sku_2fa); + SecondFactor::seed('entitlement-test@kolabnow.com'); + + $entitlement = Entitlement::where('sku_id', $sku_2fa->id) + ->where('entitleable_id', $user->id) + ->first(); + + $this->assertTrue(!empty($entitlement)); + + $sf = new SecondFactor($user); + $factors = $sf->factors(); + + $this->assertCount(2, $factors); + $this->assertSame('totp:8132a46b1f741f88de25f47e', $factors[0]); + $this->assertSame('dummy:dummy', $factors[1]); + + // Delete the entitlement, expect all configured 2FA methods in Roundcube removed + $entitlement->delete(); + + $this->assertTrue($entitlement->trashed()); + + $sf = new SecondFactor($user); + $factors = $sf->factors(); + + $this->assertCount(0, $factors); + } +} diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -255,6 +255,8 @@ $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); $this->assertEquals('bearer', $json['token_type']); + // TODO: We have browser tests for 2FA but we should probably also test it here + return $json['access_token']; } @@ -448,11 +450,13 @@ $storage_sku = Sku::where('title', 'storage')->first(); $groupware_sku = Sku::where('title', 'groupware')->first(); $mailbox_sku = Sku::where('title', 'mailbox')->first(); + $secondfactor_sku = Sku::where('title', '2fa')->first(); - $this->assertCount(3, $json['skus']); + $this->assertCount(4, $json['skus']); $this->assertSame(2, $json['skus'][$storage_sku->id]['count']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); + $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); } /** diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Auth\SecondFactor; use App\Domain; use App\Entitlement; use App\Package; diff --git a/src/tests/data/2fa-code.png b/src/tests/data/2fa-code.png new file mode 100644 index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 GIT binary patch literal 0 Hc$@