diff --git a/lib/Horde/Auth/Cascading.php b/lib/Horde/Auth/Cascading.php new file mode 100644 index 0000000..33ea4ff --- /dev/null +++ b/lib/Horde/Auth/Cascading.php @@ -0,0 +1,337 @@ + (implementation) + * @author Ralf Lang (concept) + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL-2.1 + * @package Auth + */ + +/** + * The Horde_Auth_Cascading class provides a way to combine multiple + * authentication drivers for local overrides and integration scenarios + * + * @author Florian Frank (implementation) + * @author Ralf Lang (concept) + * @category Horde + * @copyright 2017-2018 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL-2.1 + * @package Auth + */ +class Horde_Auth_Cascading extends Horde_Auth_Base +{ + /** + * Constructor. + * + * @param array $params Required parameters: + *
+     * 'drivers' - array hash of (Horde_Auth_Base) The list of backend drivers.
+     * 'order' - a list of drivers indexes to define a default order.
+     * 
+ * + * 'capabilities' - defines capabilities this driver + * exposes and how to map them to the backends. + * Defaults to "order" for all drivers which support it. + * + * @throws InvalidArgumentException + */ + public function __construct(array $params = array()) + { + $this->_test = $params; + foreach (array('drivers', 'order') as $val) { + if (!isset($params[$val])) { + throw new InvalidArgumentException('Missing ' . $val . ' parameter.'); + } + } + $capabilities = array(); + // Autodetect capabilities and build a default execution order + foreach ($this->_capabilities as $capabilityKey => $capability) { + foreach ($params['order'] as $driverKey) { + if ($params['drivers'][$driverKey]->hasCapability($capabilityKey)) { + if (empty($capabilities[$capabilityKey])) { + /* TODO: resetpassword capability is debatable - + We actually call update on the backend drivers to + get the same password in all backends + Thus, automatically assign resetpassword capability + to drivers which have 'update'. + The user may override this manually. + Drivers which actually only provide resetpassword are + supported, but more than one brings unpredictable + results + */ + $capabilities[$capabilityKey] = array(); + } + array_push($capabilities[$capabilityKey], $driverKey); + } + } + } + + if (!empty($params['capabilities'])) { + // override default capabilities with provided capabilities + $capabilities = array_merge($capabilities, $params['capabilities']); + + } + $params['capabilities'] = $capabilities; + // TODO: unset order, we don't use it after initialization + // Do base initialisation + unset($params['order']); + parent::__construct($params); + } + + protected $_test = array(); //test array + + public function getParams() //test function to see all arrays + { + return($this->_test); + } + + /** + * Find out if a set of login credentials are valid. + * Valid means valid in any backend + * + * @param string $userId The userId to check. + * @param array $credentials The credentials to use. + * + * @throws Horde_Auth_Exception + */ + protected function _authenticate($userId, $credentials) + { + // Return if any driver accepts this userId and credentials as valid + foreach ($this->_params['capabilities']['authenticate'] as $driverKey) { + if ($this->_params['drivers'][$driverKey]->authenticate($userId, $credentials)) { + return; + } + } + // Otherwise throw an exception + throw new Horde_Auth_Exception('', Horde_Auth::REASON_BADLOGIN); + } + + /** + * Advertise capability if a capability has backend driver + * + * @param string $capability The capability to test for. + * + * @return boolean Whether or not the capability is supported. + */ + public function hasCapability($capability) + { + if (empty($this->_params['capabilities'][$capability])) { + return false; + } + return true; + } + + /** + * Automatic authentication. + * + * @return boolean Whether or not the client is allowed. + */ + public function transparent() + { + // TODO: Check each configured driver in $this->_params['capabilities']['transparent']. Stop and return true if successful. Throw Exception if no driver available + + if (!$this->hasCapability('transparent')) + { + throw new Horde_Auth_Exception('Unsupported.'); + } + foreach ($this->_params['capabilities']['transparent'] as $driverKey) { + try{ + if ($this->_params['drivers']['driverKey']->transparent()) { + return true; + } + }catch (Horde_Auth_Exception $e){ + } + } + return false; + } + + /** + * Add a set of authentication credentials. + * + * @param string $userId The userId to add. + * @param array $credentials The credentials to use. + * + * @throws Horde_Auth_Exception + */ + public function addUser($userId, $credentials) + { + // TODO try to add the user to all backends in $this->_params['capabilities']['add'] - throw exception if no driver available + if (!$this->hasCapability('add')) { + throw new Horde_Auth_Exception('Unsupported.'); + } + foreach ($this->_params['capabilities']['add'] as $driverKey) { + try{ + $this->_params['drivers'][$driverKey]->addUser($userId, $credentials); + }catch (Horde_Auth_Exception $e) { + } + } + } +/** + * Update a set of authentication credentials. + * + * @param string $oldID The old userId. + * @param string $newID The new userId. + * @param array $credentials The new credentials + * + * @throws Horde_Auth_Exception + */ + public function updateUser($oldID, $newID, $credentials) + { + // TODO try to add the user to all backends in $this->_params['capabilities']['update'] - throw exception if no driver available + if (!$this->hasCapability('update')) { + throw new Horde_Auth_Exception('Unsupported.'); + } + foreach ($this->_params['capabilities']['update'] as $driverKey) { + try{ + $this->_params['drivers'][$driverKey]->updateUser($oldID, $newID, $credentials); + } catch (Horde_Auth_Exception $e) { + } + } + } + + /** + * Reset a user's password. Used for example when the user does not + * remember the existing password. + * + * @param string $userId The user id for which to reset the password. + * + * @return string The new password on success. + * @throws Horde_Auth_Exception + */ + public function resetPassword($userId) + { + // Implement this later: + // Check the list of auth drivers for drivers which has resetpassword but not update capability (configured). + // If exists, remove these drivers from list and run resetPassword on these driver. Use the first returned password for all drivers which have update (see below) + // Else, do as below + + if (!$this->hasCapability('resetpassword')) { + throw new Horde_Auth_Exception('Unsupported.'); + } + $newPassword = ''; + $resetButUpdate = array(); + foreach ($this->_params['capabilities']['resetpassword'] as $resetKey) { + if (in_array($resetKey, $this->_params['capabilities']['update'])) { + array_push($resetButUpdate, $resetKey); + } + else { + try { + $newPassword = $this->_params['drivers'][$resetKey]->resetPassword($userId); + } catch (Horde_Auth_Exception $e) { + } + } + } + // Implement this first: + // Generate a random password ONCE + // Try to update the user password to all backends - throw exception if no driver available + // Return the new random password + + // inspired by https://stackoverflow.com/questions/4356289/php-random-string-generator + + if (!empty($resetButUpdate)) { + if (newPassword == '') { + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $charactersLength = strlen($characters); + for ($i = 0; $i < 8; $i++) { + $newPassword .= $characters[rand(0, $charactersLength - 1)]; + } + } + $credentials = array('password' => $newPassword); + foreach ($resetButUpdate as $driverKey) { + try{ + $this->_params['drivers'][$driverKey]->updateUser($userId, $userId, $credentials); + } catch (Horde_Auth_Exception $e) { + } + } + } + return $newPassword; + } + + /** + * Delete a set of authentication credentials. + * + * @param string $userId The userId to delete. + * + * @throws Horde_Auth_Exception + */ + public function removeUser($userId) + { + // TODO try to remove the user from all backends in $this->_params['capabilities']['remove'] - throw exception if no driver available + if (!$this->hasCapability('remove')) { + throw new Horde_Auth_Exception('Unsupported.'); + } + $users = array(); + foreach ($this->_params['capabilities']['remove'] as $driverKey) { + try{ + $this->_params['drivers'][$driverKey]->removeUser($userId); + } catch (Horde_Auth_Exception $e) { + } + } + } + + /** + * Lists all users in the system. + * + * @param boolean $sort Sort the users? + * + * @return array The array of userIds. + * @throws Horde_Auth_Exception + */ + public function listUsers($sort = false) + { + //Todo list all users from all backends and merge them with array_unique + // Merge the results - don't list any user twice + + if (!$this->hasCapability('list')) { + throw new Horde_Auth_Exception('Unsupported.'); + } + $users = array(); + foreach ($this->_params['capabilities']['list'] as $driverKey) { + try { + $users = array_merge($users , $this->_params['drivers'][$driverKey]->listUsers($sort)); + } catch (Horde_Auth_Exception $e) { + } + } + $uniqueUsers = array_values(array_unique($users)); + return($uniqueUsers); + } + + /** + * Checks if a userId exists in the system. + * + * @param string $userId User ID to check + * + * @return boolean Whether or not the userId already exists. + */ + public function exists($userId) + { + // rotate through all backends which have list capabddlity or exists capability - return true if any backend has this user, otherwise return false. + if (!$this->hasCapability('list') and !$this->hasCapability('exists')) { + throw new Horde_Auth_Exception('Unsupported.'); + } + if ($this->hasCapability('exists')) { + foreach ($this->_params['capabilities']['exists'] as $existsKey) { + try { + if ($this->_params['drivers'][$existsKey]->exists($userId)) { + return true; + } + } catch (Horde_Auth_Exception $e) { + } + } + } + foreach ($this->_params['capabilities']['list'] as $listKey) { + try { + if (in_array($userId, $this->_params['drivers'][$listKey]->listUsers())) { + return true; + } + } catch (Horde_Auth_Exception $e) { + } + } + return false; + } +} diff --git a/test/Horde/Auth/Unit/CascadingMockTest.php b/test/Horde/Auth/Unit/CascadingMockTest.php new file mode 100644 index 0000000..b2df34d --- /dev/null +++ b/test/Horde/Auth/Unit/CascadingMockTest.php @@ -0,0 +1,169 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL-2.1 + * @link http://pear.horde.org/index.php?package=Auth + */ + +class Horde_Auth_Unit_CascadingTest extends Horde_Auth_TestCase +{ + + function setUp() { + $d1 = new Horde_Auth_Mock(array('users' => array('user1' => 'pw1','user2' => 'pw2'))); + $d2 = new Horde_Auth_Mock(array('users' => array('user1' => 'pw3443243', 'tester2' => 'pw3323242'))); + $d3 = new Horde_Auth_Mock( + array( + 'users' => array( + 'tester1337' => '$1$W1n1f.uf$4pL90iQvvfS0PqshMaMLi.', //testpassword1 + 'tester1338' => '$1$W1n1f.uf$M3F2kPykfT3Z1ywuAqvO6.', //testpassword2 + 'tester2' => '$1$W1n1f.uf$ng08WnPr8wQuVGg2pvw2f1' //hi$M+i3Rd + ), + 'encryption' => 'crypt-md5', + 'show_encryption' => false + ) + ); + $this->cascading = new Horde_Auth_Cascading( + array( + 'drivers' => array('admins' => $d1, 'db' => $d2, 'cryptdb' => $d3), + 'order' => array('admins', 'db', 'cryptdb'), + ) + ); + } + + public function testHasCapability() + { + $this->assertFalse($this->cascading->hasCapability('transparent')); + $this->assertTrue($this->cascading->hasCapability('authenticate')); + $this->assertTrue($this->cascading->hasCapability('remove')); + $this->assertTrue($this->cascading->hasCapability('update')); + } + + public function testTransparent() + { + //throw exception if no backend provides transparent + $this->setExpectedException(Horde_Auth_Exception::class); + $this->cascading->transparent(); + } + + public function testListUsers() + { + $this->assertEquals(array('user1', 'user2', 'tester2', 'tester1337', 'tester1338'), $this->cascading->listUsers(true)); + $this->assertEquals(array('user1', 'user2', 'tester2', 'tester1337', 'tester1338'), $this->cascading->listUsers(false)); + $this->assertEquals(array('user1', 'user2', 'tester2', 'tester1337', 'tester1338'), $this->cascading->listUsers()); + $this->assertCount(5, $this->cascading->listUsers()); + $this->assertCount(5, $this->cascading->listUsers(true)); + $this->assertCount(5, $this->cascading->listUsers(false)); + } + + public function testRemoveUser() + { + $this->cascading->removeUser('user1'); + $this->assertCount(4, $this->cascading->listUsers()); + $this->assertFalse($this->cascading->exists('user1')); + $this->assertFalse($this->cascading->authenticate('user1', array('password' => 'pw1'))); + $this->assertFalse($this->cascading->authenticate('user1', array('password' => 'pw3443243'))); + } + + public function testUpdateUser() + { + //change password + $this->cascading->updateUser('user1', 'user1', array('password' => 'foo')); + $this->assertCount(5, $this->cascading->listUsers()); + $this->assertTrue($this->cascading->exists('user1')); + // check if new password works + $this->assertFalse($this->cascading->authenticate('user1', array('password' => 'pw1'))); + $this->assertTrue($this->cascading->authenticate('user1', array('password' => 'foo'))); + //change userID + $this->cascading->updateUser('user2', 'user42', array('password' => 'pw2')); + $this->assertCount(5, $this->cascading->listUsers()); + $this->assertTrue($this->cascading->exists('user42')); + // check if new user works + $this->assertTrue($this->cascading->authenticate('user42', array('password' => 'pw2'))); + //check if old users still can log in + $this->assertFalse($this->cascading->authenticate('user2', array('password' => 'pw2'))); + //change userID and password + $this->cascading->updateUser('tester2', 'tester1339', array('password' => 'pw1337')); + $this->assertCount(5, $this->cascading->listUsers()); + $this->assertTrue($this->cascading->exists('tester1339')); + // check if new user can log in + $this->assertTrue($this->cascading->authenticate('tester1339', array('password' => 'pw1337'))); + //check if old users still exists + $this->assertFalse($this->cascading->authenticate('tester2', array('password' => 'pw3323242'))); + //update user with encrypted password + $this->cascading->updateUser('tester1337', 'tester1337', array('password' => 'testpassword1337')); + $this->assertTrue($this->cascading->authenticate('tester1337', array('password' => 'testpassword1337'))); + } + + public function testUpdateUserFailDoesNotExist() + { + // Try renaming unknown user + // $this->setExpectedException(Horde_Auth_Exception::class); + $this->cascading->updateUser('unknownuser', 'newname', array('password' => 'foo')); + $this->setUp(); + } + + public function testResetPassword() + { + $this->setExpectedException(Horde_Auth_Exception::class); + $newPassword = $this->cascading->resetPassword('user1'); + //old Password should not work + $this->assertFalse($this->cascading->authenticate('user1' , array('password' => 'pw1'))); + //new password should work + $this->assertTrue($this->cascading->authenticate('user1' , array('password' => $newPassword))); + } + + public function testAddUser() + { + //new user who does not exist in any backend + $this->cascading->addUser('user42', array('password' => 'foo')); + $this->assertCount(6, $this->cascading->listUsers()); + //new user who already exists in one backend with the same password + $this->cascading->addUser('user2', array('password' => 'pw2')); + $this->assertCount(6, $this->cascading->listUsers()); + //new user who already exists in one backend with a wrong password + $this->cascading->addUser('user2', array('password' => 'pw42')); + $this->assertCount(6, $this->cascading->listUsers()); + $this->assertFalse($this->cascading->authenticate('user2' , array('password' => 'pw42'))); + $this->assertTrue($this->cascading->authenticate('user2' , array('password' => 'pw2'))); + //new user who already exists in all backends + $this->cascading->addUser('user1', array('password' => 'pw1')); + $this->assertEquals(array('user1', 'user2', 'user42', 'tester2', 'tester1337', 'tester1338'), $this->cascading->listUsers(true)); + $this->assertTrue($this->cascading->authenticate('user1' , array('password' => 'pw3443243'))); + //check authenticate + $this->assertTrue($this->cascading->authenticate('user42', array('password' => 'foo'))); + $this->assertFalse($this->cascading->authenticate('user42', array('password' => 'bar'))); + } + + public function testAuthenticate() + { + // user does not exist in any backend + $this->assertFalse($this->cascading->authenticate('user42', array('password' => 'pw1'))); + // user exists in two backends with different passwords + $this->assertTrue($this->cascading->authenticate('user1', array('password' => 'pw3443243'))); + $this->assertTrue($this->cascading->authenticate('user1', array('password' => 'pw1'))); + // user exists in one backend + $this->assertTrue($this->cascading->authenticate('user2', array('password' => 'pw2'))); + //user has encrypeted password + $this->assertTrue($this->cascading->authenticate('tester1337', array('password' => 'testpassword1'))); + $this->assertTrue($this->cascading->authenticate('tester2', array('password' => 'hi$M+i3Rd'))); + // user has wrong password + $this->assertFalse($this->cascading->authenticate('user1', array('password' => 'pw42'))); + } + + public function testExists() + { + $this->assertTrue($this->cascading->exists('user1')); + $this->assertFalse($this->cascading->exists('user42')); + } +}