diff --git a/composer.json b/composer.json index 12f4b8d..031f427 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "zfcampus/zf-content-validation": "~1.0", "zfcampus/zf-hal": "~1.0", "zfcampus/zf-mvc-auth": "~1.0", - "zfcampus/zf-rest": "~1.0" + "zfcampus/zf-rest": "~1.0", + "predis/predis": "1.0.3" }, "require-dev": { diff --git a/config/autoload/auth.local.php.dist b/config/autoload/auth.local.php.dist new file mode 100644 index 0000000..c31e1a1 --- /dev/null +++ b/config/autoload/auth.local.php.dist @@ -0,0 +1,9 @@ + [ + ], +]; diff --git a/module/Api/Module.php b/module/Api/Module.php index fc40123..a103f58 100644 --- a/module/Api/Module.php +++ b/module/Api/Module.php @@ -1,6 +1,12 @@ getApplication()->getEventManager(); + + $eventManager->attach( + MvcAuthEvent::EVENT_AUTHENTICATION_POST, + array($this, 'eventAuthenticationPost'), + 900 + ); + + $moduleRouteListener = new ModuleRouteListener(); + $moduleRouteListener->attach($eventManager); + + $event->getApplication()->getEventManager()->attach( + MvcEvent::EVENT_DISPATCH_ERROR, + array($this, 'dispatchError'), + 9000 + ); + } + + public function eventAuthenticationPost(MvcAuthEvent $event) + { + // Manupilating Identity Data + $identity = $event->getIdentity(); + $oauth2Closure = $event->getMvcEvent() + ->getApplication() + ->getServiceManager() + ->get(\ZF\OAuth2\Service\OAuth2Server::class); + + if (!!$identity) { + if ($identity instanceof \ZF\MvcAuth\Identity\AuthenticatedIdentity) { + $userData = $oauth2Closure()->getStorage('user_credentials')->getUser($identity->getName()); + if (is_array($identity->getAuthenticationIdentity())) { + $userData = array_merge($userData, $identity->getAuthenticationIdentity()); + } + $identity = new \ZF\MvcAuth\Identity\AuthenticatedIdentity($userData); + $event->setIdentity($identity); + } + //MvcEvent did not understand when manipulated MvcAuthEvent identity + $event->getMvcEvent()->setParam('ZF\MvcAuth\Identity', $identity); + } + + return $event; + } + + public function dispatchError(MvcEvent $event) + { + $problem = null; + if ($event->isError()) { + $exception = $event->getParam("exception"); + + // There are some other errors like that : + // "error-controller-cannot-dispatch", + // "error-controller-invalid", + // "error-controller-not-found", + // "error-router-no-match", + if ($event->getError() === 'error-controller-not-found') { + $problem = new ApiProblem(404, "Endpoint controller not found!"); + } elseif ($event->getError() === 'error-router-no-match') { + $problem = new ApiProblem(404, "Not found!"); + } elseif ($exception instanceof \Exception) { + $className = explode('\\', get_class($exception)); + $problem = new ApiProblem($exception->getCode(), end($className) . ' error.'); + + if ($event->getTarget() instanceof ServiceLocatorAwareInterface) { + $logger = $event->getTarget()->getServiceLocator()->get('logger'); + } else { + $logger = $event->getTarget()->getServiceManager()->get('logger'); + } + + $logger = $event->getTarget()->getServiceManager()->get('logger'); + $logger->err($exception->getMessage(), array( + 'controller' => $event->getControllerClass(), + )); + } + } else { + $problem = new ApiProblem(500, "Unknown Error!"); + } + + $response = new ApiProblemResponse($problem); + $event->stopPropagation(); + + return $response; + } } diff --git a/module/Api/config/module.config.php b/module/Api/config/module.config.php index 95818cc..f67a4a9 100644 --- a/module/Api/config/module.config.php +++ b/module/Api/config/module.config.php @@ -2,7 +2,10 @@ return [ 'service_manager' => [ 'factories' => [ - 'Api\V1\User\UserResource' => 'Api\V1\User\UserResourceFactory', + Api\V1\User\UserResource::class => Api\V1\User\UserResourceFactory::class, + Api\OAuth\Storage\Adapter\Redis::class => Api\OAuth\Storage\Adapter\RedisFactory::class, + Api\OAuth\Storage\Adapter\Pdo::class => Api\OAuth\Storage\Adapter\PdoFactory::class, + ZF\OAuth2\Service\OAuth2Server::class => ZF\MvcAuth\Factory\NamedOAuth2ServerFactory::class, ], ], 'router' => array( @@ -15,7 +18,36 @@ 'controller' => 'Api\V1\User\Controller', ), ), - ), + ), // end of api.rest.user + 'oauth' => array( + 'options' => array( + 'route' => '/oauth', + ), + 'type' => 'Segment', // regex type will be remove. + 'child_routes' => array( + 'token' => array( + 'type' => 'Zend\Mvc\Router\Http\Literal', + 'options' => array( + 'route' => '/token', + 'defaults' => array( + 'action' => 'token', + ), + ), + ), + 'resource' => array( + 'type' => 'Zend\Mvc\Router\Http\Literal', + 'options' => array( + 'route' => '', + ), + ), + 'code' => array( + 'type' => 'Zend\Mvc\Router\Http\Literal', + 'options' => array( + 'route' => '', + ), + ), + ) + ), // end of oauth ), ), 'zf-versioning' => array( @@ -25,7 +57,7 @@ ), 'zf-rest' => array( 'Api\V1\User\Controller' => array( - 'listener' => 'Api\V1\User\UserResource', + 'listener' => Api\V1\User\UserResource::class, 'route_name' => 'api.rest.user', 'route_identifier_name' => 'user_id', 'collection_name' => 'user', @@ -38,8 +70,8 @@ 'collection_query_whitelist' => array(), 'page_size' => 25, 'page_size_param' => null, - 'entity_class' => 'Api\V1\User\UserEntity', - 'collection_class' => 'Api\V1\User\UserCollection', + 'entity_class' => Api\V1\User\UserEntity::class, + 'collection_class' => Api\V1\User\UserCollection::class, 'service_name' => 'User', ), ), @@ -67,7 +99,7 @@ 'entity_identifier_name' => 'id', 'route_name' => 'api.rest.user', 'route_identifier_name' => 'user_id', - 'hydrator' => 'Zend\Stdlib\Hydrator\ObjectProperty', + 'hydrator' => Zend\Stdlib\Hydrator\ObjectProperty::class, ), 'Api\V1\User\UserCollection' => array( 'entity_identifier_name' => 'id', @@ -87,8 +119,54 @@ ), ), - 'zf-mvc-auth' => array( - 'authorization' => array( + 'zf-mvc-auth' => [ + 'authorization' => [ + 'deny_by_default' => true, + 'ZF\OAuth2\Controller\Auth' => [ + 'actions' => [ + 'token' => [ + 'POST' => false, + ], + 'authorize' => [ + 'GET' => false, + 'POST' => false, + ], + ], + ], + ], + 'authentication' => [ + 'adapters' => [ + 'zingatOAuth2' => [ + 'adapter' => ZF\MvcAuth\Authentication\OAuth2Adapter::class, + 'storage' => [] + ], + ], + 'map' => [ + 'Api\V1' => 'zingatOAuth2', + ], + 'access_lifetime' => 7200, + ], + ], + 'zf-oauth2' => [ + 'storage' => [ + 'client_credentials' => \Api\OAuth\Storage\Adapter\Pdo::class, + 'user_credentials' => \Api\OAuth\Storage\Adapter\Pdo::class, + 'access_token' => \Api\OAuth\Storage\Adapter\Redis::class, + 'scope' => \Api\OAuth\Storage\Adapter\Pdo::class, + 'authorization_code' => \Api\OAuth\Storage\Adapter\Redis::class, + 'refresh_token' => \Api\OAuth\Storage\Adapter\Redis::class, + ], + 'grant_types' => [ + 'client_credentials' => true, // Default Value + 'authorization_code' => true, // Default Value + 'password' => true, // Default Value + 'refresh_token' => true, // Default Value + 'jwt' => false, + ], + 'allow_implicit' => false, + 'options' => array( + 'always_issue_new_refresh_token' => true, ), - ), + 'access_lifetime' => 7200, + ], ]; diff --git a/module/Api/src/Api/Exception/ApplicationException.php b/module/Api/src/Api/Exception/ApplicationException.php new file mode 100644 index 0000000..3708cba --- /dev/null +++ b/module/Api/src/Api/Exception/ApplicationException.php @@ -0,0 +1,13 @@ + + */ +namespace Api\Exception; + +class ApplicationException extends \Exception +{ + // Maybe in future, in here, we should create some static factory methods +} diff --git a/module/Api/src/Api/Exception/AuthException.php b/module/Api/src/Api/Exception/AuthException.php new file mode 100644 index 0000000..03d922d --- /dev/null +++ b/module/Api/src/Api/Exception/AuthException.php @@ -0,0 +1,13 @@ + + */ +namespace Api\Exception; + +class AuthException extends \Exception +{ + // Maybe in future, in here, we should create some static factory methods +} diff --git a/module/Api/src/Api/Exception/CacheException.php b/module/Api/src/Api/Exception/CacheException.php new file mode 100644 index 0000000..9c909c4 --- /dev/null +++ b/module/Api/src/Api/Exception/CacheException.php @@ -0,0 +1,13 @@ + + */ +namespace Api\Exception; + +class CacheException extends \Exception +{ + // Maybe in future, in here, we should create some static factory methods +} diff --git a/module/Api/src/Api/Exception/DatabaseException.php b/module/Api/src/Api/Exception/DatabaseException.php new file mode 100644 index 0000000..46926d4 --- /dev/null +++ b/module/Api/src/Api/Exception/DatabaseException.php @@ -0,0 +1,13 @@ + + */ +namespace Api\Exception; + +class DatabaseException extends \Exception +{ + // Maybe in future, in here, we should create some static factory methods +} diff --git a/module/Api/src/Api/OAuth/Storage/Adapter/Pdo.php b/module/Api/src/Api/OAuth/Storage/Adapter/Pdo.php new file mode 100644 index 0000000..6717bdc --- /dev/null +++ b/module/Api/src/Api/OAuth/Storage/Adapter/Pdo.php @@ -0,0 +1,103 @@ + + */ +namespace Api\OAuth\Storage\Adapter; + +use Api\OAuth\Storage\Adapter\Redis as RedisAdapter; +use OAuth2\Storage\Pdo as PdoStorage; + +/** + * Following grant_types implemented + * + * - user_credentials + * - scope + * + * Database Table Scripts: + * CREATE TABLE oauth_scopes (scope TEXT, is_default BOOLEAN); + * CREATE TABLE oauth_clients (client_id VARCHAR(80) NOT NULL, client_secret VARCHAR(80), redirect_uri VARCHAR(2000) NOT NULL, grant_types VARCHAR(80), scope VARCHAR(100), user_id VARCHAR(80), CONSTRAINT clients_client_id_pk PRIMARY KEY (client_id)); + * + * Example Data: + * + * INSERT INTO oauth_scopes (scope, is_default) VALUES ('default', 1); + * INSERT INTO oauth_clients (client_id, client_secret, redirect_uri, grant_types, scope, user_id) + * VALUES ('TestClient', 'TestSecret', 'http://api.boilerplate.local', 'password client_credentials, refresh_token', 'default', 'admin@boilerplate.local'); + * + * Redis Data Example Run on Command Line Interface: + * + * SET oauth_clients:TestClient '{"client_id":"TestClient","client_secret":"TestSecret","username":"test","password":"pass"}' + * + */ +class Pdo extends PdoStorage +{ + /** + * + * @var RedisAdapter Redis Adapter + */ + protected $redis; + + public function setRedis($redis) + { + $this->redis = $redis; + } + + public function getRedis() + { + return $this->redis; + } + + public function checkUserCredentials($username, $password) + { + if ($user = $this->getUser($username)) { + return $this->checkPassword($user, $password); + } + + return false; + } + + /** + * {@inheritdoc} + */ + protected function checkPassword($user, $password) + { + return \Core\Service\RegistrationService::verifyRawPassword($user['password'], $password); + } + + /** + * {@inheritdoc} + */ + public function getUser($username) + { + $userInfo = $this->getRedis()->getUserData($username); + if ($userInfo) { + $userInfo['from_cache'] = true; + + return array_merge(array( + 'user_id' => $username + ), $userInfo); + } + + $stmt = $this->db->prepare('SELECT id, email, password, language, registration_date from users where email = :username'); + $stmt->execute(array('username' => $username)); + + if (!$userInfo = $stmt->fetch(\PDO::FETCH_ASSOC)) { + return false; + } + + $this->getRedis()->setUserData($username, $userInfo); + // the default behavior is to use "username" as the user_id + $userInfo['from_cache'] = false; + + return array_merge(array( + 'user_id' => $username + ), $userInfo); + } + + public function setUser($username, $password, $firstName = null, $lastName = null) + { + throw new \Exception('You can not create user this way.'); + } +} diff --git a/module/Api/src/Api/OAuth/Storage/Adapter/PdoFactory.php b/module/Api/src/Api/OAuth/Storage/Adapter/PdoFactory.php new file mode 100644 index 0000000..dcb1d28 --- /dev/null +++ b/module/Api/src/Api/OAuth/Storage/Adapter/PdoFactory.php @@ -0,0 +1,37 @@ + + */ +namespace Api\OAuth\Storage\Adapter; + +use Api\Exception\DatabaseException; +use Api\Oauth\Storage\Adapter\Pdo as PdoAdapter; +use Zend\ServiceManager\FactoryInterface; +use Zend\ServiceManager\ServiceLocatorInterface; + +/** + * Pdo Adapter Factory + */ +class PdoFactory implements FactoryInterface +{ + /** + * Simply returns Pdo Adapter Storage + * + * @throws DatabaseException; + * @return PdoAdapter + */ + public function createService(ServiceLocatorInterface $services) + { + try { + $pdoAdapter = new PdoAdapter($services->get('doctrine.entitymanager.orm_default')->getConnection()->getWrappedConnection()); + $pdoAdapter->setRedis($services->get(\Api\OAuth\Storage\Adapter\Redis::class)); + + return $pdoAdapter; + } catch (\Exception $e) { + throw new DatabaseException('Database connection did not created!', 500, $e); + } + } +} diff --git a/module/Api/src/Api/OAuth/Storage/Adapter/Redis.php b/module/Api/src/Api/OAuth/Storage/Adapter/Redis.php new file mode 100644 index 0000000..728d565 --- /dev/null +++ b/module/Api/src/Api/OAuth/Storage/Adapter/Redis.php @@ -0,0 +1,38 @@ + + */ +namespace Api\OAuth\Storage\Adapter; + +use OAuth2\Storage\Redis as RedisStorage; + +/** + * Redis Adapter Class + * We use Redis currently following grant_types: + * - access_token, + * - authorization_code, + * - refresh_token, + * + * @see Api/config/module.config.php > $['zf-oauth2']['storage'] + * + */ +class Redis extends RedisStorage +{ + public function getUserData($username) + { + return $this->getValue($this->config['user_data_cache_key'].$username); + } + + public function setUserData($username, $userInfo) + { + return $this->setValue($this->config['user_data_cache_key'].$username, $userInfo); + } + + public function expireUserData($username) + { + return $this->expireValue($this->config['user_data_cache_key'].$username); + } +} diff --git a/module/Api/src/Api/OAuth/Storage/Adapter/RedisFactory.php b/module/Api/src/Api/OAuth/Storage/Adapter/RedisFactory.php new file mode 100644 index 0000000..c80976d --- /dev/null +++ b/module/Api/src/Api/OAuth/Storage/Adapter/RedisFactory.php @@ -0,0 +1,40 @@ + + */ +namespace Api\OAuth\Storage\Adapter; + +use Api\Exception\CacheException; +use Zend\ServiceManager\FactoryInterface; +use Zend\ServiceManager\ServiceLocatorInterface; + +/** + * Redis Adapter Service Factory + */ +class RedisFactory implements FactoryInterface +{ + /** + * Simply returns Redis Adapter Storage + * + * @throws CacheException + * @return Redis + */ + public function createService(ServiceLocatorInterface $services) + { + $config = $services->get('Config'); + if (!isset($config['caches']['core.cache.redis']['adapter']['options']['server'])) { + throw new CacheException('Missing redis configuration!'); + } + + try { + $redisClient = new \Predis\Client($config['caches']['core.cache.redis']['adapter']['options']['server']); + + return new Redis($redisClient, ['user_data_cache_key' => 'user_data_cache:']); + } catch (\Exception $e) { + throw new CacheException('Cache client exception', 500, $e); + } + } +} diff --git a/module/Api/src/Api/V1/User/UserResource.php b/module/Api/src/Api/V1/User/UserResource.php index f00f29c..f503c25 100644 --- a/module/Api/src/Api/V1/User/UserResource.php +++ b/module/Api/src/Api/V1/User/UserResource.php @@ -38,6 +38,10 @@ public function fetch($id) */ public function fetchAll($params = []) { + // Example usage identity information + // $identity = $this->getIdentity()->getAuthenticationIdentity(); + + return [['foo' => 'bar']]; } } diff --git a/module/Core/src/Core/Fixture/BaseFixture.php b/module/Core/src/Core/Fixture/BaseFixture.php index 5508f01..4f49de7 100644 --- a/module/Core/src/Core/Fixture/BaseFixture.php +++ b/module/Core/src/Core/Fixture/BaseFixture.php @@ -54,7 +54,7 @@ public function getOrder() * Overrided by all derived childs. * * @param ObjectManager $manager - * @return voşid + * @return void */ public function load(ObjectManager $manager) { diff --git a/module/Core/src/Core/Service/RegistrationService.php b/module/Core/src/Core/Service/RegistrationService.php index f5a737f..d1c57b4 100644 --- a/module/Core/src/Core/Service/RegistrationService.php +++ b/module/Core/src/Core/Service/RegistrationService.php @@ -8,9 +8,9 @@ namespace Core\Service; use Core\Entity\User as UserEntity; -use Zend\Authentication\AuthenticationService; -use DoctrineModule\Persistence\ObjectManagerAwareInterface; use Core\Traits\ObjectManagerAwareTrait; +use DoctrineModule\Persistence\ObjectManagerAwareInterface; +use Zend\Authentication\AuthenticationService; class RegistrationService extends AbstractService implements ObjectManagerAwareInterface { @@ -62,14 +62,14 @@ public function login($email, $password, $rememberMe = false) * * @static * - * @param UserEntity $user + * @param string $passwordHashed * @param string $passwordGiven * * @return boolean */ - public static function verifyPassword(UserEntity $user, $passwordGiven) + public static function verifyRawPassword($passwordHashed, $passwordGiven) { - $verified = password_verify($passwordGiven, $user->getPassword()); + $verified = password_verify($passwordGiven, $passwordHashed); if ($verified) { // You may also want to check user status here. @@ -80,6 +80,24 @@ public static function verifyPassword(UserEntity $user, $passwordGiven) return false; } + /** + * Verifies given password by given user credentials (using password salt) + * when user trying to login the system first time. + * + * Called by doctrinemodule's authentication configuration on login. + * + * @static + * + * @param UserEntity $user + * @param string $passwordGiven + * + * @return boolean + */ + public static function verifyPassword(UserEntity $user, $passwordGiven) + { + return self::verifyRawPassword($user->getPassword(), $passwordGiven); + } + /** * Properly hashes password using bcrypt. *