From c751ae901a09843b9e9c5876735b4ce6df8c9c95 Mon Sep 17 00:00:00 2001 From: mattamon Date: Thu, 29 Feb 2024 16:59:49 +0100 Subject: [PATCH] Initial authorization prototype --- config/api_platform/resources/token.yaml | 20 +++ config/serialization/token.yaml | 10 ++ config/services.yaml | 2 + src/DependencyInjection/Configuration.php | 18 ++- .../PimcoreStudioApiExtension.php | 8 ++ src/Dto/Token.php | 37 +++++ src/Dto/Token/Info.php | 43 ++++++ src/Dto/Token/Output.php | 50 +++++++ src/Dto/Token/Refresh.php | 31 +++++ src/Service/SecurityService.php | 36 +++++ src/Service/SecurityServiceInterface.php | 22 +++ src/State/Token/PostProcessor.php | 128 ++++++++++++++++++ src/State/Token/PutProcessor.php | 93 +++++++++++++ .../Resources/Token/PostProcessorTest.php | 51 +++++++ 14 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 config/api_platform/resources/token.yaml create mode 100644 config/serialization/token.yaml create mode 100644 src/Dto/Token.php create mode 100644 src/Dto/Token/Info.php create mode 100644 src/Dto/Token/Output.php create mode 100644 src/Dto/Token/Refresh.php create mode 100644 src/Service/SecurityService.php create mode 100644 src/Service/SecurityServiceInterface.php create mode 100644 src/State/Token/PostProcessor.php create mode 100644 src/State/Token/PutProcessor.php create mode 100644 tests/Unit/Resources/Token/PostProcessorTest.php diff --git a/config/api_platform/resources/token.yaml b/config/api_platform/resources/token.yaml new file mode 100644 index 000000000..a7ee882b5 --- /dev/null +++ b/config/api_platform/resources/token.yaml @@ -0,0 +1,20 @@ +resources: + Pimcore\Bundle\StudioApiBundle\Dto\Token: + operations: + ApiPlatform\Metadata\Post: + processor: Pimcore\Bundle\StudioApiBundle\State\Token\PostProcessor + output: Pimcore\Bundle\StudioApiBundle\Dto\Token\Output + uriTemplate: '/token/create' + openapiContext: + summary: 'Creates and returns a token' + + ApiPlatform\Metadata\Put: + processor: Pimcore\Bundle\StudioApiBundle\State\Token\PutProcessor + input: Pimcore\Bundle\StudioApiBundle\Dto\Token\Refresh + output: Pimcore\Bundle\StudioApiBundle\Dto\Token\Output + uriTemplate: '/token/refresh' + openapiContext: + summary: 'Refreshes an existing token' + + normalizationContext: + groups: [ 'token:read' ] \ No newline at end of file diff --git a/config/serialization/token.yaml b/config/serialization/token.yaml new file mode 100644 index 000000000..4a995fe82 --- /dev/null +++ b/config/serialization/token.yaml @@ -0,0 +1,10 @@ +Pimcore\Bundle\StudioApiBundle\Dto\Token\Output: + attributes: + token: + groups: ['token:read'] + username: + groups: [ 'token:read' ] + lifetime: + groups: ['token:read'] + validUntil: + groups: ['token:read'] diff --git a/config/services.yaml b/config/services.yaml index de85f81de..c255445bb 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -27,6 +27,8 @@ services: # Processors Pimcore\Bundle\StudioApiBundle\State\ResetPasswordProcessor: ~ + Pimcore\Bundle\StudioApiBundle\State\Token\PostProcessor: ~ + Pimcore\Bundle\StudioApiBundle\State\Token\PutProcessor: ~ # Filters Pimcore\Bundle\StudioApiBundle\Filter\AssetParentIdFilter: diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 86dee49de..60c1771da 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -13,6 +13,7 @@ namespace Pimcore\Bundle\StudioApiBundle\DependencyInjection; + use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -23,11 +24,26 @@ */ class Configuration implements ConfigurationInterface { + public const ROOT_NODE = 'pimcore_studio_api'; + /** * {@inheritdoc} */ public function getConfigTreeBuilder(): TreeBuilder { - return new TreeBuilder('pimcore_studio_api'); + $treeBuilder = new TreeBuilder(self::ROOT_NODE); + + $rootNode = $treeBuilder->getRootNode(); + $rootNode->addDefaultsIfNotSet(); + $rootNode->children() + ->arrayNode('api_token') + ->addDefaultsIfNotSet() + ->children() + ->integerNode('lifetime') + ->defaultValue(3600) + ->end() + ->end(); + + return $treeBuilder; } } diff --git a/src/DependencyInjection/PimcoreStudioApiExtension.php b/src/DependencyInjection/PimcoreStudioApiExtension.php index 6ba89f3b7..dcfcf2d21 100644 --- a/src/DependencyInjection/PimcoreStudioApiExtension.php +++ b/src/DependencyInjection/PimcoreStudioApiExtension.php @@ -14,6 +14,8 @@ namespace Pimcore\Bundle\StudioApiBundle\DependencyInjection; use Exception; +use Pimcore\Bundle\StudioApiBundle\State\Token\PostProcessor; +use Pimcore\Bundle\StudioApiBundle\State\Token\PutProcessor; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; @@ -55,6 +57,12 @@ public function load(array $configs, ContainerBuilder $container): void 'pimcore_studio_api.serializer.mapping.paths', $config['serializer']['mapping']['paths'] ); + + $definition = $container->getDefinition(PostProcessor::class); + $definition->setArgument('$tokenLifetime', $config['api_token']['lifetime']); + + $definition = $container->getDefinition(PutProcessor::class); + $definition->setArgument('$tokenLifetime', $config['api_token']['lifetime']); } public function prepend(ContainerBuilder $container): void diff --git a/src/Dto/Token.php b/src/Dto/Token.php new file mode 100644 index 000000000..b85e989fb --- /dev/null +++ b/src/Dto/Token.php @@ -0,0 +1,37 @@ +username; + } + + public function getPassword(): string + { + return $this->password; + } +} \ No newline at end of file diff --git a/src/Dto/Token/Info.php b/src/Dto/Token/Info.php new file mode 100644 index 000000000..e1006aa52 --- /dev/null +++ b/src/Dto/Token/Info.php @@ -0,0 +1,43 @@ +token; + } + + public function getTmpStoreId(): string + { + return $this->tmpStoreId; + } + + public function getUsername(): string + { + return $this->username; + } +} \ No newline at end of file diff --git a/src/Dto/Token/Output.php b/src/Dto/Token/Output.php new file mode 100644 index 000000000..520bad0ca --- /dev/null +++ b/src/Dto/Token/Output.php @@ -0,0 +1,50 @@ +token; + } + + public function getUsername(): string + { + return $this->username; + } + + public function getLifetime(): int + { + return $this->lifetime; + } + + public function validUntil(): string + { + return Carbon::now()->addSeconds($this->lifetime)->toDateTimeString(); + } +} \ No newline at end of file diff --git a/src/Dto/Token/Refresh.php b/src/Dto/Token/Refresh.php new file mode 100644 index 000000000..79db406b9 --- /dev/null +++ b/src/Dto/Token/Refresh.php @@ -0,0 +1,31 @@ +token; + } +} \ No newline at end of file diff --git a/src/Service/SecurityService.php b/src/Service/SecurityService.php new file mode 100644 index 000000000..b5045aa5f --- /dev/null +++ b/src/Service/SecurityService.php @@ -0,0 +1,36 @@ +tmpStoreResolver->getIdsByTag($token); + if(count($userIds) === 0 || count($userIds) > 1) { + return false; + } + $entry = $this->tmpStoreResolver->get($userIds[0]); + return $entry && $entry->getTag() === $token; + } +} \ No newline at end of file diff --git a/src/Service/SecurityServiceInterface.php b/src/Service/SecurityServiceInterface.php new file mode 100644 index 000000000..58beb7f50 --- /dev/null +++ b/src/Service/SecurityServiceInterface.php @@ -0,0 +1,22 @@ +getUriTemplate() !== self::OPERATION_URI_TEMPLATE + ) { + // wrong operation + throw new OperationNotFoundException(); + } + + /** @var User $user */ + $user = $this->checkUserAndPassword($data); + + $token = $this->generateToken(); + + $this->saveTokenToTmpStore( + new Info( + $token, + $this->getTmpStoreId($user->getUserIdentifier()), + $user->getUserIdentifier() + ) + ); + + return new Output($token, $this->tokenLifetime, $user->getUserIdentifier()); + } + + + private function checkUserAndPassword($data): PasswordAuthenticatedUserInterface + { + try { + $user = $this->userProvider->loadUserByIdentifier($data->getUsername()); + } catch (UserNotFoundException) { + throw new AccessDeniedException('Invalid credentials'); + } + + if( + !$user instanceof PasswordAuthenticatedUserInterface || + !$this->passwordHasher->isPasswordValid($user, $data->getPassword()) + ){ + throw new AccessDeniedException('Invalid credentials'); + } + + return $user; + } + + private function generateToken(): string + { + do { + $token = $this->tokenGenerator->generateToken(); + $ids = $this->tmpStoreResolver->getIdsByTag($token); + } while (count($ids) > 0); + + return $token; + } + + private function saveTokenToTmpStore(Info $tokenInfo): void + { + $this->tmpStoreResolver->set($tokenInfo->getTmpStoreId(), [ + 'username' => $tokenInfo->getUsername(), + ], $tokenInfo->getToken(), $this->tokenLifetime); + } + + private function getTmpStoreId(string $userId): string + { + return str_replace( + self::TMP_STORE_ID_PLACEHOLDER, + $userId, + self::TMP_STORE_ID + ); + } +} \ No newline at end of file diff --git a/src/State/Token/PutProcessor.php b/src/State/Token/PutProcessor.php new file mode 100644 index 000000000..a3b441d1b --- /dev/null +++ b/src/State/Token/PutProcessor.php @@ -0,0 +1,93 @@ +getUriTemplate() !== self::OPERATION_URI_TEMPLATE + ) { + // wrong operation + throw new OperationNotFoundException(); + } + + $tokenInfo = $this->getTmpStoreIdByToken($data->getToken()); + + $this->saveTokenToTmpStore($tokenInfo); + + return new Output($data->getToken(), $this->tokenLifetime, $tokenInfo->getUsername()); + } + + + private function getTmpStoreIdByToken(string $token): Info + { + $userIds = $this->tmpStoreResolver->getIdsByTag($token); + if(count($userIds) === 0 || count($userIds) > 1) { + throw new TokenNotFoundException('Token not found'); + } + /** @var TmpStore $entry */ + $entry = $this->tmpStoreResolver->get($userIds[0]); + + $data = $entry->getData(); + + if(!isset($data['username'])) { + throw new TokenNotFoundException('Token not found'); + } + + return new Info($token, $entry->getId(), $data['username']); + } + + + private function saveTokenToTmpStore(Info $tokenInfo): void + { + $this->tmpStoreResolver->set($tokenInfo->getTmpStoreId(), [ + 'username' => $tokenInfo->getUsername() + ], $tokenInfo->getToken(), $this->tokenLifetime); + } +} \ No newline at end of file diff --git a/tests/Unit/Resources/Token/PostProcessorTest.php b/tests/Unit/Resources/Token/PostProcessorTest.php new file mode 100644 index 000000000..983799733 --- /dev/null +++ b/tests/Unit/Resources/Token/PostProcessorTest.php @@ -0,0 +1,51 @@ +mockProcessor(); + $this->assertInstanceOf(ProcessorInterface::class, $processor); + } + + private function mockProcessor(): ProcessorInterface + { + return new PostProcessor( + $this->mockUserProvider(), + $this->mockTokenGenerator(), + $this->mockPasswordHasher(), + $this->mockTmpStoreResolver(), + self::TOKEN_TTL + ); + } + + private function mockUserProvider(): UserProvider { + return $this->makeEmpty(UserProvider::class); + } + + private function mockTokenGenerator(): TokenGeneratorInterface + { + return $this->makeEmpty(TokenGeneratorInterface::class); + } + + private function mockPasswordHasher(): UserPasswordHasherInterface + { + return $this->makeEmpty(UserPasswordHasherInterface::class); + } + + private function mockTmpStoreResolver(): TmpStoreResolverInterface + { + return $this->makeEmpty(TmpStoreResolverInterface::class); + } +} \ No newline at end of file