diff --git a/.gitignore b/.gitignore index 4eb0d0b..4721e56 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ vendor/ composer.lock .env +.idea diff --git a/composer.json b/composer.json index a06b68d..c985094 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Graphael: GraphQL Server builder", "homepage": "http://www.github.com/linkorb/graphael", "keywords": ["graphael", "graphql", "linkorb"], - "type": "application", + "type": "symfony-bundle", "authors": [ { "name": "Joost Faassen", @@ -12,21 +12,20 @@ } ], "require": { - "php": ">=7.2", - "symfony/options-resolver": "~3.0", - "symfony/dependency-injection": "~3.3", - "webonyx/graphql-php": "^0.13.8", + "php": ">=8.0", + "webonyx/graphql-php": "^15.2", "linkorb/connector": "~1.0", "firebase/php-jwt": "~5.0", "pwa/time-elapsed": "^1.0", - "symfony/security-core": "^4.2 || ^5.0 ", - "symfony/http-foundation": "^4.2 || ^5.0", + "symfony/security-core": "^6", "ext-pdo": "*", - "doctrine/cache": "^1.10" + "doctrine/cache": "^1.10", + "symfony/framework-bundle": "^6", + "symfony/security-bundle": "^6.0" }, "autoload": { "psr-4": { - "Graphael\\": "src/" + "LinkORB\\Bundle\\GraphaelBundle\\": "src/" } }, "license": "MIT" diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..9f3165e --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,70 @@ +parameters: + graphael.default_jwt_algo: 'RS256' + graphael.default_admin_role: 'ROLE_ADMIN' + graphael.default_role: 'ROLE_AUTHENTICATED' + graphael.jwt_algo: '%env(default:graphael.default_jwt_algo:string:GRAPHAEL_JWT_ALGO)%' + graphael.jwt_key: '%env(file:GRAPHAEL_JWT_KEY)%' + graphael.jwt_username_claim: '%env(string:GRAPHAEL_JWT_USERNAME_CLAIM)%' + graphael.jwt_roles_claim: '%env(string:GRAPHAEL_JWT_ROLES_CLAIM)%' + graphael.admin_role: '%env(default:graphael.default_admin_role:string:GRAPHAEL_ADMIN_ROLE)%' + graphael.jwt_default_role: '%env(default:graphael.default_role:string:GRAPHAEL_JWT_DEFAULT_ROLE)%' + graphael.cache_driver: '%env(string:GRAPHAEL_CACHE_DRIVER)%' + graphael.cache_driver_file_path: '%env(string:GRAPHAEL_CACHE_DRIVER_FILE_PATH)%' + graphael.pdo_url: '%env(string:GRAPHAEL_PDO_URL)%' + +services: + _defaults: + autowire: true + public: true + + LinkORB\Bundle\GraphaelBundle\Controller\: + resource: '../src/Controller/' + tags: ['controller.service_arguments'] + + Connector\Connector: + + PDO: + class: PDO + factory: ['LinkORB\Bundle\GraphaelBundle\Security\Factory\ConnectorFactory', 'createConnector'] + arguments: + $pdoUrl: '%graphael.pdo_url%' + + LinkORB\Bundle\GraphaelBundle\Services\DependencyInjection\ContainerTypeRegistry: + arguments: ['@service_container'] + + LinkORB\Bundle\GraphaelBundle\Services\Server: + class: LinkORB\Bundle\GraphaelBundle\Services\Server + LinkORB\Bundle\GraphaelBundle\Services\FieldResolver: + + LinkORB\Bundle\GraphaelBundle\Services\DependencyInjection\TypeRegistryInterface: + alias: LinkORB\Bundle\GraphaelBundle\Services\DependencyInjection\ContainerTypeRegistry + public: true + + LinkORB\Bundle\GraphaelBundle\Security\UserProvider\JwtDataMapperInterface: + alias: LinkORB\Bundle\GraphaelBundle\Security\UserProvider\DefaultJwtDataMapper + public: true + + LinkORB\Bundle\GraphaelBundle\Security\JwtCertManager\JwtCertManagerInterface: + alias: LinkORB\Bundle\GraphaelBundle\Security\JwtCertManager\JwtCertManager + public: true + + LinkORB\Bundle\GraphaelBundle\Security\JwtFactory: + arguments: + $jwtEnabled: '%graphael.jwt_key%' + $adminRole: '%graphael.jwt_default_role%' + + LinkORB\Bundle\GraphaelBundle\Security\UserProvider\DefaultJwtDataMapper: + + LinkORB\Bundle\GraphaelBundle\Security\UserProvider\JwtUserProvider: + + LinkORB\Bundle\GraphaelBundle\Security\JwtCertManager\JwtCertManager: + arguments: + $publicCert: '%graphael.jwt_key%' + + LinkORB\Bundle\GraphaelBundle\Security\JwtAuthenticator: + arguments: + $userProvider: '@LinkORB\Bundle\GraphaelBundle\Security\UserProvider\JwtUserProvider' + $jwtAlg: '%graphael.jwt_algo%' + + LinkORB\Bundle\GraphaelBundle\Security\Authorization\UsernameVoter: + tags: ['security.voter'] diff --git a/src/Controller/GraphController.php b/src/Controller/GraphController.php new file mode 100644 index 0000000..d06485b --- /dev/null +++ b/src/Controller/GraphController.php @@ -0,0 +1,109 @@ +headers->set('Access-Control-Allow-Origin', '*'); + + if ($request->isMethod('OPTIONS')) { + $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Authorization'); + $response->headers->set('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); + $response->setContent('{"status": "ok"}'); + + return $response; + } + + $result = $this->server->executeRequest(); + $httpStatus = $this->resolveHttpStatus($result); + + if (count($this->logger->getHandlers())>0) { + $result->setErrorsHandler(function($errors) { + foreach ($errors as $error) { + $json = json_encode($error, JSON_UNESCAPED_SLASHES); + $data = [ + 'event' => [ + 'action' => 'graphael:error', + ], + 'log' => [ + 'level' => 'error', + 'original' => json_encode(['error' => $json], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ], + ]; + $this->logger->error($error->getMessage() ?? 'Execution Error', $data); + } + return array_map('GraphQL\Error\FormattedError::createFromException', $errors); + }); + } + + $data = $result->toArray(); + $json = json_encode($data, JSON_UNESCAPED_SLASHES); + $response->setContent($json); + $response->setStatusCode($httpStatus); + + if ($httpStatus!=200) { + $data = [ + 'event' => [ + 'action' => 'graphael:error', + ], + 'log' => [ + 'level' => 'error', + 'original' => 'HTTP' . $httpStatus . ': ' . json_encode(['error' => $json], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ], + ]; + $this->logger->error('HTTP Error', $data); + } + + return $response; + } + + /** + * @param ExecutionResult|mixed[] $result + */ + private function resolveHttpStatus($result): int + { + if (is_array($result) && isset($result[0])) { + foreach ($result as $index => $executionResultItem) { + if (!$executionResultItem instanceof ExecutionResult) { + throw new InvariantViolation(sprintf( + 'Expecting every entry of batched query result to be instance of %s but entry at position %d is %s', + ExecutionResult::class, + $index, + Utils::printSafe($executionResultItem) + )); + } + } + $httpStatus = 200; + } else { + if (!$result instanceof ExecutionResult) { + throw new InvariantViolation(sprintf( + 'Expecting query result to be instance of %s but got %s', + ExecutionResult::class, + Utils::printSafe($result) + )); + } + if ($result->data === null && count($result->errors) > 0) { + $httpStatus = 400; + } else { + $httpStatus = 200; + } + } + + return $httpStatus; + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..f6f6b9c --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,34 @@ +getRootNode(); + + $rootNode + ->children() + ->scalarNode('jwt_algo')->defaultValue('%graphael.jwt_algo%')->end() + ->scalarNode('jwt_key')->defaultValue('%graphael.jwt_key%')->end() + ->scalarNode('jwt_username_claim')->defaultValue('%graphael.jwt_username_claim%')->end() + ->scalarNode('jwt_roles_claim')->defaultValue('%graphael.jwt_roles_claim%')->end() + ->scalarNode('admin_role')->defaultValue('%graphael.admin_role%')->end() + ->scalarNode('jwt_default_role')->defaultValue('%graphael.jwt_default_role%')->end() + ->scalarNode('cache_driver')->defaultValue('%graphael.cache_driver%')->end() + ->scalarNode('cache_driver_file_path')->defaultValue('%graphael.cache_driver_file_path%')->end() + ->scalarNode('pdo_url')->end() + ->scalarNode('type_namespace')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('type_path')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('type_postfix')->isRequired()->cannotBeEmpty()->end() + ->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/src/DependencyInjection/GraphaelExtension.php b/src/DependencyInjection/GraphaelExtension.php new file mode 100644 index 0000000..a60434b --- /dev/null +++ b/src/DependencyInjection/GraphaelExtension.php @@ -0,0 +1,111 @@ +load('services.yaml'); + + $configuration = new Configuration(); + $options = $this->processConfiguration($configuration, $configs); + + $this->processCacheDriver($options, $container); + + if (file_exists($options['jwt_key'])) { + $jwtKey = file_get_contents($options['jwt_key']); + $options['jwt_key'] = $jwtKey; + $container->setParameter('graphael.jwt_key', $options['jwt_key']); + } + + if ($options['pdo_url'] ?? null) { + $container->setParameter('graphael.pdo_url', $options['pdo_url']); + } + + if (!isset($options['type_namespace'])) { + throw new RuntimeException("type_namespace not configured"); + } + if (!isset($options['type_path'])) { + throw new RuntimeException("type_path not configured"); + } + + $this->processTypes($options, $container); + + $this->initializeServer($options['type_namespace'], $options['type_postfix'], $container); + } + + private function processCacheDriver(array $options, ContainerBuilder $container): void + { + $cacheDriver = $options['cache_driver'] ?? null; + try { + $cacheDriver = $container->getParameterBag()->resolveValue($cacheDriver); + } catch (ParameterNotFoundException) { + // Value is set not as parameter, so let's use it as it is + } + + switch ($cacheDriver) { + case 'file': + $container + ->register(Cache::class, PhpFileCache::class) + ->addArgument($options['cache_driver_file_path']) + ; + break; + case ''; // default unconfigured to array + case 'array': + $container + ->register(Cache::class, ArrayCache::class) + ; + break; + default: + throw new RuntimeException("Unsupported or unconfigured cache driver: " . $options['cache_driver']); + } + } + + private function processTypes(array $options, ContainerBuilder $container): void + { + // Auto register QueryTypes + foreach (glob($options['type_path'] . '/*Type.php') as $filename) { + $className = $options['type_namespace'] . '\\' . basename($filename, '.php'); + if (!is_array(class_implements($className))) { + throw new RuntimeException("Can't register class (failed to load, or does not implement anything): " . $className); + } + if (is_subclass_of($className, 'GraphQL\\Type\\Definition\\Type')) { + $container->autowire($className, $className)->setPublic(true); + } + } + } + + private function initializeServer(string $typeNamespace, string $typePostfix, ContainerBuilder $container): void + { + $container->getDefinition(Server::class) + ->addArgument(new Reference($typeNamespace . '\QueryType')) + ->addArgument(new Reference($typeNamespace . '\MutationType')) + ->addArgument([]) + ->addArgument(new Reference(AuthorizationCheckerInterface::class)) + ->addArgument('%'.Server::CONTEXT_ADMIN_ROLE_KEY.'%') + ->addArgument(new Reference('request_stack')) + ->addArgument(new Reference(FieldResolver::class)) + ->addArgument(new Reference('service_container')) + ->addArgument($typeNamespace) + ->addArgument($typePostfix) + ; + } +} diff --git a/src/Entity/Security/AuthorizationEntityInterface.php b/src/Entity/Security/AuthorizationEntityInterface.php index 1d9f775..ef4ccdf 100644 --- a/src/Entity/Security/AuthorizationEntityInterface.php +++ b/src/Entity/Security/AuthorizationEntityInterface.php @@ -1,6 +1,6 @@ serverConfig = $serverConfig; - } - - - /** - * @param ExecutionResult|mixed[] $result - * - * @return int - */ - private function resolveHttpStatus($result) - { - if (is_array($result) && isset($result[0])) { - Utils::each( - $result, - static function ($executionResult, $index) : void { - if (! $executionResult instanceof ExecutionResult) { - throw new InvariantViolation(sprintf( - 'Expecting every entry of batched query result to be instance of %s but entry at position %d is %s', - ExecutionResult::class, - $index, - Utils::printSafe($executionResult) - )); - } - } - ); - $httpStatus = 200; - } else { - if (! $result instanceof ExecutionResult) { - throw new InvariantViolation(sprintf( - 'Expecting query result to be instance of %s but got %s', - ExecutionResult::class, - Utils::printSafe($result) - )); - } - if ($result->data === null && count($result->errors) > 0) { - $httpStatus = 400; - } else { - $httpStatus = 200; - } - } - - return $httpStatus; - } - - - public function run(Request $request): Response - { - $container = $this->boot($this->serverConfig); - $this->initialize($container, $request); - - $logger = null; - if (isset($this->serverConfig[ContainerFactory::LOGGER])) { - $logger = $container->get( - $this->serverConfig[ContainerFactory::LOGGER] - ); - } - - $result = $this->server->executeRequest(); // ExecutionResult - $httpStatus = $this->resolveHttpStatus($result); - - if (count($logger->getHandlers())>0) { - $result->setErrorsHandler(function($errors) use ($logger) { - foreach ($errors as $error) { - $json = json_encode($error, JSON_UNESCAPED_SLASHES); - $data = [ - 'event' => [ - 'action' => 'graphael:error', - ], - 'log' => [ - 'level' => 'error', - 'original' => json_encode(['error' => $json], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - ], - ]; - $logger->error($error->getMessage() ?? 'Execution Error', $data); - } - return array_map('GraphQL\Error\FormattedError::createFromException', $errors); - }); - } - $data = $result->toArray(); - $json = json_encode($data, JSON_UNESCAPED_SLASHES); - $response = new Response($json, $httpStatus); - - if ($httpStatus!=200) { - $data = [ - 'event' => [ - 'action' => 'graphael:error', - ], - 'log' => [ - 'level' => 'error', - 'original' => 'HTTP' . $httpStatus . ': ' . json_encode(['error' => $json], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - ], - ]; - $logger->error('HTTP Error', $data); - } - - return $response; - } - - private function boot(array $config): ContainerInterface - { - // Create container - $container = ContainerFactory::create($config); - $container->compile(); - - return $container; - } - - private function initialize(ContainerInterface $container, Request $request): void - { - /** @var ErrorHandlerInterface $errorHandler */ - $errorHandler = $container->get(ErrorHandlerInterface::class); - $errorHandler->initialize(); - - $rootValue = []; - - $container->set(Request::class, $request); - - $logger = null; - if (isset($this->serverConfig[ContainerFactory::LOGGER])) { - $logger = $container->get( - $this->serverConfig[ContainerFactory::LOGGER] - ); - $errorHandler->setLogger($logger); - } - - /** @var SecurityFacade $securityFacade */ - $securityFacade = $container->get(SecurityFacade::class); - $securityFacade->initialize( - $request, - (bool) $container->getParameter('jwt_key'), - $container->getParameter('jwt_username_claim'), - $container->getParameter('jwt_roles_claim'), - $container->getParameter('jwt_default_role'), - $container->getParameter(Server::CONTEXT_ADMIN_ROLE_KEY) - ); - - $typeNamespace = $container->getParameter('type_namespace'); - $typePostfix = $container->getParameter('type_postfix'); - - /** @var ObjectType $queryType */ - $queryType = $container->get($typeNamespace . '\QueryType'); - /** @var ObjectType $mutationType */ - $mutationType = $container->get($typeNamespace . '\MutationType'); - - /** @var AuthorizationCheckerInterface $checker */ - $checker = $container->get(AuthorizationCheckerInterface::class); - - $this->server = new Server( - $queryType, - $mutationType, - function ($name) use ($container, $typeNamespace, $typePostfix) { - $className = $typeNamespace . '\\' . $name . $typePostfix; - return $container->get($className); - }, - $rootValue, - $checker, - $container->getParameter(Server::CONTEXT_ADMIN_ROLE_KEY), - $request, - new FieldResolver($logger) - ); - } -} diff --git a/src/Resources/config/routing/graphael.yaml b/src/Resources/config/routing/graphael.yaml new file mode 100644 index 0000000..9a46770 --- /dev/null +++ b/src/Resources/config/routing/graphael.yaml @@ -0,0 +1,3 @@ +graphael_endpoint: + path: / + controller: LinkORB\Bundle\GraphaelBundle\Controller\GraphController diff --git a/src/Security/Authorization/UsernameVoter.php b/src/Security/Authorization/UsernameVoter.php index d6b2ff8..c5524f3 100644 --- a/src/Security/Authorization/UsernameVoter.php +++ b/src/Security/Authorization/UsernameVoter.php @@ -1,8 +1,8 @@ getUsername() === $subject->getAccessedUsername(); + return $token->getUser()->getUserIdentifier() === $subject->getAccessedUsername(); } } diff --git a/src/Security/AuthorizationContextAwareTrait.php b/src/Security/AuthorizationContextAwareTrait.php index 42bc811..09812ae 100644 --- a/src/Security/AuthorizationContextAwareTrait.php +++ b/src/Security/AuthorizationContextAwareTrait.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Graphael\Security; +namespace LinkORB\Bundle\GraphaelBundle\Security; -use Graphael\Entity\Security\UsernameAuthorization; -use Graphael\Security\Authorization\UsernameVoter; -use Graphael\Server; +use LinkORB\Bundle\GraphaelBundle\Entity\Security\UsernameAuthorization; +use LinkORB\Bundle\GraphaelBundle\Security\Authorization\UsernameVoter; +use LinkORB\Bundle\GraphaelBundle\Services\Server; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; diff --git a/src/Security/Factory/ConnectorFactory.php b/src/Security/Factory/ConnectorFactory.php new file mode 100644 index 0000000..30ac090 --- /dev/null +++ b/src/Security/Factory/ConnectorFactory.php @@ -0,0 +1,25 @@ +getConfig($pdoUrl); + $mode = 'db'; + $pdoDsn = $connector->getPdoDsn($pdoConfig, $mode); + + return new PDO( + $pdoDsn, + $pdoConfig->getUsername(), + $pdoConfig->getPassword(), + [PDO::MYSQL_ATTR_FOUND_ROWS => true] + ); + } +} diff --git a/src/Security/JwtAuthenticator.php b/src/Security/JwtAuthenticator.php new file mode 100644 index 0000000..c80593b --- /dev/null +++ b/src/Security/JwtAuthenticator.php @@ -0,0 +1,116 @@ +user = $this->factory->createFromRequest($request); + } catch (OmittedJwtTokenException) { + return true; + } catch (AuthenticationException|UnexpectedValueException) { + return false; + } + + return true; + } + + /** + * @inheritDoc + */ + public function authenticate(Request $request): Passport + { + try { + $this->user = $this->user ?? $this->factory->createFromRequest($request); + } catch (OmittedJwtTokenException) { + return new SelfValidatingPassport( + new UserBadge( + uniqid(), + function(): UserInterface { + return new InMemoryUser(JwtFactory::ANONYMOUS_USER, null, []); + } + ) + ); + } + + $payload = JWT::decode( + $this->user->getPassword(), + $this->jwtManager->getPublicCertificate($this->user->getUserIdentifier()), + [$this->jwtAlg] + ); + + if (!$payload) { + throw new AuthenticationException(); + } + + $authenticatedUsername = $this->userProvider + ->loadUserByIdentifier($this->user->getUserIdentifier()) + ->getUserIdentifier(); + + $authenticatedUser = new InMemoryUser($authenticatedUsername, $this->user->getPassword(), + $this->user->getRoles()); + + return new SelfValidatingPassport( + new UserBadge( + $authenticatedUser->getUserIdentifier(), + function(string $username): UserInterface { + $user = $this->userProvider->loadUserByIdentifier($username); + if (!$user instanceof User) { + throw new AuthenticationException(sprintf('%s user object expected', User::class)); + } + + $user->setRoles($this->user->getRoles()); + + return $user; + } + ) + ); + } + + /** + * @inheritDoc + */ + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + /** + * @inheritDoc + */ + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return new Response($exception->getMessage(), Response::HTTP_UNAUTHORIZED); + } +} diff --git a/src/Security/JwtCertManager/JwtCertManager.php b/src/Security/JwtCertManager/JwtCertManager.php index ca7d9f5..56df51e 100644 --- a/src/Security/JwtCertManager/JwtCertManager.php +++ b/src/Security/JwtCertManager/JwtCertManager.php @@ -1,16 +1,12 @@ publicCert = $publicCert; - } + public function __construct( + private string $publicCert, + ) {} public function getPublicCertificate(string $username): string { diff --git a/src/Security/JwtCertManager/JwtCertManagerInterface.php b/src/Security/JwtCertManager/JwtCertManagerInterface.php index 0642da8..5655de3 100644 --- a/src/Security/JwtCertManager/JwtCertManagerInterface.php +++ b/src/Security/JwtCertManager/JwtCertManagerInterface.php @@ -1,6 +1,6 @@ jwtEnabled) { + return new InMemoryUser(static::ANONYMOUS_USER, null, [$this->adminRole]); + } + $rawJwtString = $this->extractRawJwt($request); $jwtSegments = explode('.', $rawJwtString); @@ -42,36 +49,12 @@ public function createFromRequest(Request $request): TokenInterface if (is_string($roles)) { $roles = explode(",", $roles); } - $token = new JsonWebToken($roles, $rawJwtString); if (empty($payload->{$this->usernameClaim})) { throw new AuthenticationException('No username claim passed in JWT'); } - $token->setUser($payload->{$this->usernameClaim}); - - return $token; - } - - public function setUsernameClaim(string $usernameClaim): self - { - $this->usernameClaim = $usernameClaim; - - return $this; - } - - public function setRolesClaim(string $rolesClaim): self - { - $this->rolesClaim = $rolesClaim; - - return $this; - } - - public function setDefaultRole(string $defaultRole): self - { - $this->defaultRole = $defaultRole; - - return $this; + return new InMemoryUser($payload->{$this->usernameClaim}, $rawJwtString, $roles); } private function extractRawJwt(Request $request): string diff --git a/src/Security/Provider/JwtAuthProvider.php b/src/Security/Provider/JwtAuthProvider.php deleted file mode 100644 index 74f331a..0000000 --- a/src/Security/Provider/JwtAuthProvider.php +++ /dev/null @@ -1,56 +0,0 @@ -userProvider = $userProvider; - $this->jwtManager = $jwtManager; - $this->jwtAlg = $jwtAlg; - } - - public function authenticate(TokenInterface $token): TokenInterface - { - $payload = JWT::decode( - $token->getCredentials(), - $this->jwtManager->getPublicCertificate($token->getUsername()), - [$this->jwtAlg] - ); - - if (!$payload) { - throw new AuthenticationException(); - } - - $user = $this->userProvider->loadUserByUsername($token->getUsername()); - - $authToken = new JsonWebToken($token->getRoles(), $token->getCredentials()); - $authToken->setUser($user); - $authToken->setAuthenticated(true); - - return $authToken; - } - - public function supports(TokenInterface $token): bool - { - return $token instanceof JsonWebToken; - } -} diff --git a/src/Security/SecurityFacade.php b/src/Security/SecurityFacade.php deleted file mode 100644 index 78608ae..0000000 --- a/src/Security/SecurityFacade.php +++ /dev/null @@ -1,84 +0,0 @@ -tokenStorage = $tokenStorage; - $this->authenticationManager = $authenticationManager; - $this->jwtFactory = $jwtFactory; - } - - public function initialize( - Request $request, - bool $jwtEnabled, - ?string $usernameClaim, - ?string $rolesClaim, - ?string $defaultRole, - string $adminRole - ): void - { - // Disabled jwt auth means admin role for every request - if (!$jwtEnabled) { - $token = new JsonWebToken([$adminRole]); - $token->setUser(static::ANONYMOUS_USER); - $token->setAuthenticated(true); - - $this->tokenStorage->setToken($token); - - return; - } - - try { - if ($usernameClaim) { - $this->jwtFactory->setUsernameClaim($usernameClaim); - } - - if ($rolesClaim) { - $this->jwtFactory->setRolesClaim($rolesClaim); - } - - if ($defaultRole) { - $this->jwtFactory->setDefaultRole($defaultRole); - } - - $unauthenticatedToken = $this->jwtFactory->createFromRequest($request); - - $this->tokenStorage->setToken( - $this->authenticationManager->authenticate($unauthenticatedToken) - ); - } catch (OmittedJwtTokenException $e) { - $token = new AnonymousToken(uniqid(), static::ANONYMOUS_USER, []); - $token->setAuthenticated(true); - - $this->tokenStorage->setToken($token); - } catch (Exception $e) { - throw new AuthenticationException('Token invalid', 0, $e); - } - } -} diff --git a/src/Security/Token/JsonWebToken.php b/src/Security/Token/JsonWebToken.php deleted file mode 100644 index d612ca3..0000000 --- a/src/Security/Token/JsonWebToken.php +++ /dev/null @@ -1,23 +0,0 @@ -rawToken = $rawToken; - } - - public function getCredentials(): ?string - { - return $this->rawToken; - } -} diff --git a/src/Security/UserProvider/DefaultJwtDataMapper.php b/src/Security/UserProvider/DefaultJwtDataMapper.php index 635e89d..864cdb9 100644 --- a/src/Security/UserProvider/DefaultJwtDataMapper.php +++ b/src/Security/UserProvider/DefaultJwtDataMapper.php @@ -1,8 +1,7 @@ getUsernameProperty()], null); + return new User($object[$this->getUsernameProperty()], null, []); } } diff --git a/src/Security/UserProvider/JwtDataMapperInterface.php b/src/Security/UserProvider/JwtDataMapperInterface.php index 8a68041..3abb275 100644 --- a/src/Security/UserProvider/JwtDataMapperInterface.php +++ b/src/Security/UserProvider/JwtDataMapperInterface.php @@ -1,6 +1,6 @@ dataMapper = $dataMapper; } - public function loadUserByUsername($username): UserInterface + public function loadUserByIdentifier(string $identifier): UserInterface { - return $this->getUser($username); + return $this->getUser($identifier); } public function refreshUser(UserInterface $user): UserInterface { - return $this->getUser($user->getUsername()); + return $this->getUser($user->getUserIdentifier()); } public function supportsClass($class): bool { - return $class === UserInterface::class; + $interfaces = class_implements($class); + return $interfaces !== false && in_array(UserInterface::class, $interfaces); } private function getUser(string $username): UserInterface @@ -79,10 +80,10 @@ private function getUser(string $username): UserInterface $this->dataMapper->getUserTable(), $this->dataMapper->getUsernameProperty() ); - + $stmt = $this->pdo->prepare($sql); $stmt->execute([':username' => $username]); - + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); $userData = reset($results); diff --git a/src/Security/UserProvider/User.php b/src/Security/UserProvider/User.php new file mode 100644 index 0000000..66babbb --- /dev/null +++ b/src/Security/UserProvider/User.php @@ -0,0 +1,40 @@ +username; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getRoles(): array + { + return $this->roles; + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + return $this; + } + + public function eraseCredentials() + { + $this->password = null; + } +} diff --git a/src/AbstractPdoObjectType.php b/src/Services/AbstractPdoObjectType.php similarity index 98% rename from src/AbstractPdoObjectType.php rename to src/Services/AbstractPdoObjectType.php index 954eda7..26677ae 100644 --- a/src/AbstractPdoObjectType.php +++ b/src/Services/AbstractPdoObjectType.php @@ -1,9 +1,8 @@ $value) { - $container->setParameter($key, $value); - } - foreach ($parameters as $key=>$value) { - $container->setParameter($key, $value); - } - - $cacheDriverType = $container->getParameter('cache_driver') ?? null; - switch ($cacheDriverType) { - case 'file': - $cachePath = $container->getParameter('cache_driver_file_path'); - $container - ->register(\Doctrine\Common\Cache\Cache::class, \Doctrine\Common\Cache\PhpFileCache::class) - ->addArgument($cachePath) - ; - break; - case ''; // default unconfigured to array - case 'array': - $container - ->register(\Doctrine\Common\Cache\Cache::class, \Doctrine\Common\Cache\ArrayCache::class) - ; - break; - default: - throw new RuntimeException("Unsupported or unconfigured cache driver: " . $cacheDriverType); - } - - - - if (file_exists($container->getParameter('jwt_key'))) { - $jwtKey = file_get_contents($container->getParameter('jwt_key')); - $container->setParameter('jwt_key', $jwtKey); - } - - // === setup database connection ===s - $connector = new Connector(); - $container - ->register('connector', 'Connector\Connector') - ; - - $pdoConfig = $connector->getConfig($parameters['pdo_url']); - $mode = 'db'; - $pdoDsn = $connector->getPdoDsn($pdoConfig, $mode); - - //throw new \Exception($config->getPassword()); - $container - ->register('PDO', 'PDO') - ->addArgument($pdoDsn) - ->addArgument($pdoConfig->getUsername()) - ->addArgument($pdoConfig->getPassword()) - ->addArgument([ - PDO::MYSQL_ATTR_FOUND_ROWS => true - ]) - ; - - // == register all GraphQL Types === - if (!isset($config['type_namespace'])) { - throw new RuntimeException("type_namespace not configured"); - } - if (!isset($config['type_path'])) { - throw new RuntimeException("type_path not configured"); - } - $ns = $config['type_namespace']; - $path = $config['type_path']; - if (!file_exists($path)) { - throw new RuntimeException("Invalid type_path (not found)"); - } - - // Register TypeRegister - $definition = $container->register(TypeRegistryInterface::class, ContainerTypeRegistry::class); - $definition->addArgument($container); - - static::registerSecurityServices($container, $config); - - // Auto register QueryTypes - foreach (glob($path.'/*Type.php') as $filename) { - $className = $ns . '\\' . basename($filename, '.php'); - if (!is_array(class_implements($className))) { - throw new RuntimeException("Can't register class (failed to load, or does not implement anything): " . $className); - } - if (is_subclass_of($className, 'GraphQL\\Type\\Definition\\Type')) { - self::autoRegisterClass($container, $className) - ->setPublic(true); - } - } - if (isset($config[self::POST_CONTAINER_BUILDER])) { - $postContainerBuilder = new $config[self::POST_CONTAINER_BUILDER](); - $postContainerBuilder->build($container); - } - - return $container; - - } - - public static function normalizedVoters(array $voters): array - { - $normalizedVoters = []; - - foreach ($voters as $voter) { - if (is_string($voter)) { - $normalizedVoters[] = new Reference($voter); - } elseif ($voter instanceof VoterInterface) { - $normalizedVoters[] = $voter; - } - } - - return $normalizedVoters; - } - - private static function registerSecurityServices(ContainerBuilder $container, array $config): void - { - $container->register(ErrorHandlerInterface::class, ErrorHandler::class)->setPublic(true); - $container->register(JwtFactory::class, JwtFactory::class); - - $container->setAlias(AuthenticationProviderInterface::class, JwtAuthProvider::class); - - $container->register(TokenStorageInterface::class, TokenStorage::class) - ->setPublic(true); - - $authenticationManager = $container->register( - AuthenticationManagerInterface::class, - AuthenticationProviderManager::class - ); - $authenticationManager->addArgument([new Reference(JwtAuthProvider::class)]); - - $container->register(AccessDecisionManagerInterface::class, AccessDecisionManager::class) - ->addArgument(static::normalizedVoters($config[static::AUTH_VOTERS])) - ->addArgument(AccessDecisionManager::STRATEGY_AFFIRMATIVE) - ->addArgument(false); - - static::autoRegisterClass($container, SecurityFacade::class) - ->setPublic(true); - - static::autoRegisterClass($container, AuthorizationChecker::class); - $container->setAlias(AuthorizationCheckerInterface::class, AuthorizationChecker::class) - ->setPublic(true); - - $container->register(RoleVoter::class, RoleVoter::class); - if ($config[static::ROLE_HIERARCHY]) { - $container->register(RoleHierarchyInterface::class, RoleHierarchy::class) - ->addArgument($config[static::ROLE_HIERARCHY]); - $container->register(RoleHierarchyVoter::class, RoleHierarchyVoter::class) - ->addArgument(new Reference(RoleHierarchyInterface::class)); - } - $container->register(UsernameVoter::class, UsernameVoter::class); - - static::autoRegisterClass($container, AuthenticationTrustResolver::class); - $container->setAlias(AuthenticationTrustResolverInterface::class, AuthenticationTrustResolver::class); - static::autoRegisterClass($container, AuthenticatedVoter::class); - - $container->register(JwtCertManager::class, JwtCertManager::class) - ->addArgument($container->getParameter('jwt_key')); - - static::registerOrAlias($container, JwtCertManagerInterface::class, $config[JwtCertManagerInterface::class]); - - $container->register(DefaultJwtDataMapper::class, DefaultJwtDataMapper::class); - - static::registerOrAlias($container, JwtDataMapperInterface::class, $config[JwtDataMapperInterface::class]); - - if ($container->has(JwtDataMapperInterface::class)) { - $container->register(JwtUserProvider::class, JwtUserProvider::class) - ->addArgument(new Reference(PDO::class)) - ->addArgument(new Reference(JwtDataMapperInterface::class)); - } - - static::registerOrAlias($container, UserProviderInterface::class, $config[UserProviderInterface::class]); - - $authProviderDefinition = $container->register(JwtAuthProvider::class, JwtAuthProvider::class); - $authProviderDefinition->addArgument(new Reference(UserProviderInterface::class)); - $authProviderDefinition->addArgument(new Reference(JwtCertManagerInterface::class)); - $authProviderDefinition->addArgument($container->getParameter('jwt_algo')); - } - - private static function getParameters($prefix) - { - // Load parameters from environment - $parameters = []; - foreach ($_ENV as $key=>$value) { - if (substr($key, 0, strlen($prefix))==$prefix) { - if (is_numeric($value)) { - $value = (int)$value; - } - $parameters[strtolower(substr($key, strlen($prefix)))] = $value; - } - } - - // Validate parameters - $resolver = new OptionsResolver(); - - $resolver->setDefaults(array( - 'debug' => false, - 'jwt_algo' => 'RS256', - 'jwt_key' => null, - 'jwt_username_claim' => null, - 'jwt_roles_claim' => null, - Server::CONTEXT_ADMIN_ROLE_KEY => 'ROLE_ADMIN', - 'jwt_default_role' => null, - 'cache_driver' => null, - 'cache_driver_file_path' => null, - )); - $resolver->setAllowedTypes('jwt_key', ['string', 'null']); - $resolver->setAllowedTypes('jwt_username_claim', ['string', 'null']); - $resolver->setAllowedTypes('jwt_roles_claim', ['string', 'null']); - $resolver->setAllowedTypes(Server::CONTEXT_ADMIN_ROLE_KEY, ['string']); - $resolver->setAllowedTypes('jwt_default_role', ['string', 'null']); - $resolver->setRequired('pdo_url'); - - return $resolver->resolve($parameters); - } - - private static function autoRegisterClass(ContainerBuilder $container, $className): Definition - { - $reflectionClass = new ReflectionClass($className); - $constructor = $reflectionClass->getConstructor(); - $definition = $container->register($className, $className); - foreach ($constructor->getParameters() as $p) { - $reflectionClass = $p->getClass(); - if ($reflectionClass) { - $definition->addArgument(new Reference($reflectionClass->getName())); - } - } - - return $definition; - } - - private static function registerOrAlias(ContainerBuilder $container, string $id, $value): void - { - if (is_object($value)) { - $container->set($id, $value); - } elseif (is_string($value)) { - $container->setAlias($id, $value); - } - } -} diff --git a/src/Services/DependencyInjection/ContainerTypeRegistry.php b/src/Services/DependencyInjection/ContainerTypeRegistry.php index 2c9579d..634e910 100644 --- a/src/Services/DependencyInjection/ContainerTypeRegistry.php +++ b/src/Services/DependencyInjection/ContainerTypeRegistry.php @@ -1,6 +1,6 @@ logger = $logger; - } - - public function onShutdown(): void - { - $error = error_get_last(); - - if ($error) { - ob_end_clean(); // ignore the buffer - echo json_encode(['error' => $error], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - if ($this->logger) { - $data = [ - 'event' => [ - 'action' => 'graphael:error', - ], - 'log' => [ - 'level' => 'error', - 'original' => json_encode(['error' => $error], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - ], - ]; - $this->logger->info('Error Handler', $data); - } - - return; - } - } -} diff --git a/src/Services/Error/ErrorHandlerInterface.php b/src/Services/Error/ErrorHandlerInterface.php deleted file mode 100644 index 1c553a5..0000000 --- a/src/Services/Error/ErrorHandlerInterface.php +++ /dev/null @@ -1,14 +0,0 @@ -eventLogger = $eventLogger; } diff --git a/src/Server.php b/src/Services/Server.php similarity index 56% rename from src/Server.php rename to src/Services/Server.php index c014a72..099f03e 100644 --- a/src/Server.php +++ b/src/Services/Server.php @@ -1,47 +1,53 @@ $queryType, 'mutation' => $mutationType, - 'typeLoader' => $typeLoader, + 'typeLoader' => function ($name) use ($container, $typeNamespace, $typePostfix) { + $className = $typeNamespace . '\\' . $name . $typePostfix; + return $container->get($className); + }, ] ); $config = [ 'schema' => $schema, - 'debug' => Debug::INCLUDE_DEBUG_MESSAGE | Debug::INCLUDE_TRACE, + 'debugFlag' => DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE, 'rootValue' => $rootValue, 'fieldResolver' => [$resolver, 'resolve'], 'context' => [ static::CONTEXT_AUTHORIZATION_KEY => $authorizationChecker, static::CONTEXT_ADMIN_ROLE_KEY => $adminRole, - static::CONTEXT_IP_KEY => $request->getClientIp(), + static::CONTEXT_IP_KEY => $requestStack->getCurrentRequest()->getClientIp(), ], ];