diff --git a/composer.json b/composer.json index ff59374..6d7f0a2 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ }, "require": { "php": ">=5.5", - "zendframework/zend-stdlib": "~2.5" + "zendframework/zend-stdlib": "~2.5", + "zendframework/zend-eventmanager": "^3.0.0" }, "require-dev": { "zendframework/zend-db": "~2.5", diff --git a/cookbook/2fa.php b/cookbook/2fa.php new file mode 100644 index 0000000..33dfeba --- /dev/null +++ b/cookbook/2fa.php @@ -0,0 +1,73 @@ +getResult(); + if ($result->isValid()) { + $prevResult = $event->getPreviousResult(); + $identity = $result->getIdentity(); + + if ($prevResult !== null) { + $identity = $prevResult->getIdentity(); + } + + if (isset($identity['do2fa']) && $identity['do2fa']) { + $twoFactorResponse = $event->getParam('twoFactorResponse'); + + if (isset($twoFactorResponse)) { + if ( + $prevResult !== null && + isset($prevResult->twoFactorToken) && + $twoFactorResponse === $prevResult->twoFactorToken + ) { + $result = new \Zend\Authentication\Result(\Zend\Authentication\Result::SUCCESS, $identity); + $event->setResult($result); + + return $result; + } + } + + $result = new \Zend\Authentication\Result(-4, $identity, 'Requires 2 factor Auth'); + $result->twoFactorToken = 'efg456'; //generate randomly + $event->setResult($result); + $event->stopPropagation(); + + return $result; + + } + } + + return $result; + } +} + +$twoFaListener = new TwoFAListener(); + +$callback = function ($identity, $credential) { + if ($identity === $credential) { + return new \Zend\Stdlib\ArrayObject(['identity' => $identity, 'credential' => $credential, 'do2fa' => true]); + } + + throw new \Exception('Authentication failed'); +}; + +$adapter = new \Zend\Authentication\Adapter\Callback($callback); + +$authService = new AuthenticationService(null, $adapter); +$authService->addListener('Authenticate', [$TwoFAlistener, 'onAuthenticate'], 20); + +$authService->authenticate(['identity' => 'test', 'credential' => 'test']); + +//result success with test identity +$authService->authenticate(['twoFactorResponse' => 'efg456']); \ No newline at end of file diff --git a/cookbook/AuditLog.php b/cookbook/AuditLog.php new file mode 100644 index 0000000..da27ea9 --- /dev/null +++ b/cookbook/AuditLog.php @@ -0,0 +1,43 @@ +log = $log; + } + + public function onAuthenticationFailed(\Zend\Authentication\Event\Authenticate $event) + { + $this->log->warn( + sprintf('Authenication Failure for (%s) from (%s)', $event->getParam('identity'), $event->getParam('ip')) + ); + } +} + +$auditLog = new AuditLog(new stdClass()); + +$callback = function ($identity, $credential) { + if ($identity === $credential) { + return new \Zend\Stdlib\ArrayObject(['identity' => $identity, 'credential' => $credential]); + } + + throw new \Exception('Authentication failed'); +}; + +$adapter = new \Zend\Authentication\Adapter\Callback($callback); + +$authService = new AuthenticationService(null, $adapter); +$authService->addListener('AuthenticationFailed', [$auditLog, 'onAuthenticationFailed'] , -1); + +$authService->authenticate(['ip' => '127.0.0.1', 'identity' => 'test', 'credential' => 'failed']); diff --git a/cookbook/BruteForceProtection.php b/cookbook/BruteForceProtection.php new file mode 100644 index 0000000..b5da61c --- /dev/null +++ b/cookbook/BruteForceProtection.php @@ -0,0 +1,79 @@ +authFails[$identifier])) { + return $this->authFails[$identifier]; + } + + return 0; + } + + public function onAuthenticate(\Zend\Authentication\Event\Authenticate $event) + { + $identity = $event->getParam('identity'); + if ($identity !== null) { + if ($this->getFailurecount($identity) > 2) { + $result = new \Zend\Authentication\Result(-4, $identity, 'Too many failed attempts'); + $event->setResult($result); + $event->stopPropagation(); + + return $result; + } + } + + $ip = $event->getParam('ip'); + if ($ip !== null) { + if ($this->getFailurecount($ip) > 2) { + $result = new \Zend\Authentication\Result(-4, $ip, 'Too many failed attempts'); + $event->setResult($result); + $event->stopPropagation(); + + return $result; + } + } + } + + public function onAuthenticationFailed(\Zend\Authentication\Event\Authenticate $event) + { + $identity = $event->getParam('identity'); + if ($identity !== null) { + $this->authFails[$identity]++; + } + + $ip = $event->getParam('ip'); + if ($ip !== null) { + $this->authFails[$ip]++; + } + } +} + +$firewall = new Firewall(); + +$callback = function ($identity, $credential) { + if ($identity === $credential) { + return new \Zend\Stdlib\ArrayObject(['identity' => $identity, 'credential' => $credential]); + } + + throw new \Exception('Authentication failed'); +}; + +$adapter = new \Zend\Authentication\Adapter\Callback($callback); + +$authService = new AuthenticationService(null, $adapter, 10); +$authService->addListener('Authenticate', [$firewall, 'onAuthenticate'] , -1); +$authService->addListener('AuthenticationFailed', [$firewall, 'onAuthenticationFailed'] , -1); + +$authService->authenticate(['ip' => '127.0.0.1', 'identity' => 'test', 'credential' => 'failed']); +$authService->authenticate(['ip' => '127.0.0.1', 'identity' => 'test', 'credential' => 'failed']); +$authService->authenticate(['ip' => '127.0.0.1', 'identity' => 'test2', 'credential' => 'failed']); + +// result = failure to many attempts +$authService->authenticate(['ip' => '127.0.0.1', 'identity' => 'test2', 'credential' => 'failed']); \ No newline at end of file diff --git a/cookbook/ChainedAuthentication.php b/cookbook/ChainedAuthentication.php new file mode 100644 index 0000000..4252b27 --- /dev/null +++ b/cookbook/ChainedAuthentication.php @@ -0,0 +1,34 @@ + $identity, 'credential' => $credential]); + } + + throw new \Exception('Authentication failed'); +}; + +$adapter = new \Zend\Authentication\Adapter\Callback($callback); + +$callback2 = function ($identity, $credential) { + if ($identity === 'test' && $credential === 'tester') { + return new \Zend\Stdlib\ArrayObject(['identity' => $identity, 'credential' => $credential]); + } + + throw new \Exception('Authentication failed'); +}; + +$adapter2 = new \Zend\Authentication\Adapter\Callback($callback2); + +$authService = new AuthenticationService(null, $adapter, 10); +$authService->addAdapter($adapter2, 20); + +//auths against adapter 1 +$authService->authenticate(['identity' => 'test', 'credential' => 'test']); + +//auths against adapter 2 +$authService->authenticate(['identity' => 'test', 'credential' => 'tester']); + diff --git a/src/AuthenticationService.php b/src/AuthenticationService.php index 06e82e2..acb8171 100644 --- a/src/AuthenticationService.php +++ b/src/AuthenticationService.php @@ -9,6 +9,13 @@ namespace Zend\Authentication; +use Zend\Authentication\Event\Authenticate; +use Zend\Authentication\Event\AuthenticationFailed; +use Zend\Authentication\Event\AuthenticationSucceeded; +use Zend\Authentication\Listener\AuthenticationAdapterListener; +use Zend\EventManager\EventManager; +use Zend\EventManager\EventManagerInterface; + class AuthenticationService implements AuthenticationServiceInterface { /** @@ -21,49 +28,28 @@ class AuthenticationService implements AuthenticationServiceInterface /** * Authentication adapter * - * @var Adapter\AdapterInterface + * @var EventManagerInterface */ - protected $adapter = null; + protected $events; /** * Constructor * - * @param Storage\StorageInterface $storage - * @param Adapter\AdapterInterface $adapter + * @param EventManagerInterface $eventManager + * @param Storage\StorageInterface $storage */ - public function __construct(Storage\StorageInterface $storage = null, Adapter\AdapterInterface $adapter = null) + public function __construct(Storage\StorageInterface $storage = null, Adapter\AdapterInterface $adapter = null, $priority = 10) { if (null !== $storage) { $this->setStorage($storage); } - if (null !== $adapter) { - $this->setAdapter($adapter); + + $this->events = new EventManager(); + if ($adapter !== null) { + $this->addAdapter($adapter, $priority); } } - /** - * Returns the authentication adapter - * - * The adapter does not have a default if the storage adapter has not been set. - * - * @return Adapter\AdapterInterface|null - */ - public function getAdapter() - { - return $this->adapter; - } - - /** - * Sets the authentication adapter - * - * @param Adapter\AdapterInterface $adapter - * @return AuthenticationService Provides a fluent interface - */ - public function setAdapter(Adapter\AdapterInterface $adapter) - { - $this->adapter = $adapter; - return $this; - } /** * Returns the persistent storage handler @@ -96,18 +82,33 @@ public function setStorage(Storage\StorageInterface $storage) /** * Authenticates against the supplied adapter * - * @param Adapter\AdapterInterface $adapter + * @? This is currently a BC break the original takes an auth adapter as a parameter. This still conforms to the interface though. + * + * @param array $authenticationContext * @return Result - * @throws Exception\RuntimeException */ - public function authenticate(Adapter\AdapterInterface $adapter = null) + public function authenticate($authenticationContext = []) { - if (!$adapter) { - if (!$adapter = $this->getAdapter()) { - throw new Exception\RuntimeException('An adapter must be set or passed prior to calling authenticate()'); - } + $event = new Authenticate(); + $event->setTarget($this); + $event->setParams($authenticationContext); + $event->setPreviousResult($this->getResult()); + + $this->events->triggerEvent($event); + + $result = $event->getResult(); + + if ($result->isValid()) { + $event = new AuthenticationSucceeded(); + } else { + $event = new AuthenticationFailed(); } - $result = $adapter->authenticate(); + + $event->setTarget($this); + $event->setResult($result); + $event->setParams($authenticationContext); + + $this->events->trigger($event); /** * ZF-7546 - prevent multiple successive calls from storing inconsistent results @@ -117,9 +118,7 @@ public function authenticate(Adapter\AdapterInterface $adapter = null) $this->clearIdentity(); } - if ($result->isValid()) { - $this->getStorage()->write($result->getIdentity()); - } + $this->getStorage()->write($result); return $result; } @@ -131,7 +130,7 @@ public function authenticate(Adapter\AdapterInterface $adapter = null) */ public function hasIdentity() { - return !$this->getStorage()->isEmpty(); + return !$this->getStorage()->isEmpty() && $this->getStorage()->read()->isValid(); } /** @@ -141,13 +140,13 @@ public function hasIdentity() */ public function getIdentity() { - $storage = $this->getStorage(); + $result = $this->getResult(); - if ($storage->isEmpty()) { - return; + if ($result !== null && $result->isValid()) { + return $result->getIdentity(); } - return $storage->read(); + return null; } /** @@ -159,4 +158,41 @@ public function clearIdentity() { $this->getStorage()->clear(); } + + /** + * @param Adapter\AdapterInterface $adapter + * @param int $priority + */ + public function addAdapter(Adapter\AdapterInterface $adapter, $priority = 10) + { + $listener = new AuthenticationAdapterListener($adapter); + $this->events->attach('Authenticate', [$listener, 'onAuthenticate'], $priority); + } + + /** + * @? Should there be a separate method for each event type instead of passing the event as a string? + * + * @param $event + * @param callable $listener + * @param $priority + */ + public function addListener($event, callable $listener, $priority) + { + $this->events->attach($event, $listener, $priority); + } + + /** + * @return Result|null + */ + private function getResult() + { + $storage = $this->getStorage(); + + if ($storage->isEmpty()) { + return null; + } + + $result = $storage->read(); + return $result; + } } diff --git a/src/Event/Authenticate.php b/src/Event/Authenticate.php new file mode 100644 index 0000000..1eb8d9b --- /dev/null +++ b/src/Event/Authenticate.php @@ -0,0 +1,46 @@ +result; + } + + /** + * @param mixed $result + */ + public function setResult($result) + { + $this->result = $result; + } + + /** + * @return mixed + */ + public function getPreviousResult() + { + return $this->previousResult; + } + + /** + * @param mixed $previousResult + */ + public function setPreviousResult($previousResult) + { + $this->previousResult = $previousResult; + } +} \ No newline at end of file diff --git a/src/Event/AuthenticationFailed.php b/src/Event/AuthenticationFailed.php new file mode 100644 index 0000000..f2ab250 --- /dev/null +++ b/src/Event/AuthenticationFailed.php @@ -0,0 +1,28 @@ +result; + } + + /** + * @param mixed $result + */ + public function setResult($result) + { + $this->result = $result; + } +} \ No newline at end of file diff --git a/src/Event/AuthenticationSucceeded.php b/src/Event/AuthenticationSucceeded.php new file mode 100644 index 0000000..2095cdb --- /dev/null +++ b/src/Event/AuthenticationSucceeded.php @@ -0,0 +1,28 @@ +result; + } + + /** + * @param mixed $result + */ + public function setResult($result) + { + $this->result = $result; + } +} \ No newline at end of file diff --git a/src/Listener/AuthenticationAdapterListener.php b/src/Listener/AuthenticationAdapterListener.php new file mode 100644 index 0000000..cc70df4 --- /dev/null +++ b/src/Listener/AuthenticationAdapterListener.php @@ -0,0 +1,45 @@ +adapter = $adapter; + } + + public function onAuthenticate(Authenticate $event) + { + $result = $event->getResult(); + if ($result instanceof Result && $result->isValid()) { + //If a previous adapter has already returned a valid result don't change that + return null; + } + + if ($this->adapter instanceof ValidatableAdapterInterface) { + $this->adapter->setIdentity($event->getParam('identity')); + $this->adapter->setCredential($event->getParam('credential')); + } + + $result = $this->adapter->authenticate(); + + $event->setResult($result); + + return $result; + } +} \ No newline at end of file