diff --git a/src/include/Kolab2FA/Driver/Base.php b/src/include/Kolab2FA/Driver/Base.php index 79eab42e..c8b19ce9 100644 --- a/src/include/Kolab2FA/Driver/Base.php +++ b/src/include/Kolab2FA/Driver/Base.php @@ -1,345 +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/tests/Feature/Auth/SecondFactorTest.php b/src/tests/Feature/Auth/SecondFactorTest.php index 8f7e96c7..511369b9 100644 --- a/src/tests/Feature/Auth/SecondFactorTest.php +++ b/src/tests/Feature/Auth/SecondFactorTest.php @@ -1,63 +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); } }