From b6c6dfac2dda0570cc27f3429132a24962e9f852 Mon Sep 17 00:00:00 2001 From: Akolzin Dmitry Date: Thu, 4 Feb 2021 18:06:23 +0300 Subject: [PATCH] add authenticators, tests --- ArgumentResolver/AbstractValueResolver.php | 8 +- ArgumentResolver/CallbackValueResolver.php | 8 +- ArgumentResolver/ClientValueResolver.php | 80 +++++++++++ .../RetailCrmServiceExtension.php | 22 +++ .../InvalidRequestArgumentException.php | 35 +++++ Models/Error.php | 21 +++ Response/ErrorJsonResponseFactory.php | 27 ++++ Security/AbstractClientAuthenticator.php | 61 ++++++++ Security/CallbackClientAuthenticator.php | 18 +++ Security/FrontApiClientAuthenticator.php | 35 +++++ .../CallbackValueResolverTest.php | 108 +++++++++++++++ .../ClientValueResolverTest.php | 131 ++++++++++++++++++ Tests/DataFixtures/RequestDto.php | 14 ++ Tests/DataFixtures/User.php | 32 +++++ .../CallbackClientAuthenticatorTest.php | 130 +++++++++++++++++ .../FrontApiClientAuthenticatorTest.php | 118 ++++++++++++++++ composer.json | 8 +- 17 files changed, 851 insertions(+), 5 deletions(-) create mode 100644 ArgumentResolver/ClientValueResolver.php create mode 100644 Exceptions/InvalidRequestArgumentException.php create mode 100644 Models/Error.php create mode 100644 Response/ErrorJsonResponseFactory.php create mode 100644 Security/AbstractClientAuthenticator.php create mode 100644 Security/CallbackClientAuthenticator.php create mode 100644 Security/FrontApiClientAuthenticator.php create mode 100644 Tests/ArgumentResolver/CallbackValueResolverTest.php create mode 100644 Tests/ArgumentResolver/ClientValueResolverTest.php create mode 100644 Tests/DataFixtures/RequestDto.php create mode 100644 Tests/DataFixtures/User.php create mode 100644 Tests/Security/CallbackClientAuthenticatorTest.php create mode 100644 Tests/Security/FrontApiClientAuthenticatorTest.php diff --git a/ArgumentResolver/AbstractValueResolver.php b/ArgumentResolver/AbstractValueResolver.php index 638db72..95a75e1 100644 --- a/ArgumentResolver/AbstractValueResolver.php +++ b/ArgumentResolver/AbstractValueResolver.php @@ -2,8 +2,8 @@ namespace RetailCrm\ServiceBundle\ArgumentResolver; +use RetailCrm\ServiceBundle\Exceptions\InvalidRequestArgumentException; use Symfony\Component\Validator\Validator\ValidatorInterface; -use InvalidArgumentException; abstract class AbstractValueResolver { @@ -21,7 +21,11 @@ abstract class AbstractValueResolver { $errors = $this->validator->validate($data); if (0 !== count($errors)) { - throw new InvalidArgumentException($errors); + throw new InvalidRequestArgumentException( + sprintf("Invalid request parameter %s", \get_class($data)), + 400, + $errors + ); } } } diff --git a/ArgumentResolver/CallbackValueResolver.php b/ArgumentResolver/CallbackValueResolver.php index 7d0a30c..25ebcdc 100644 --- a/ArgumentResolver/CallbackValueResolver.php +++ b/ArgumentResolver/CallbackValueResolver.php @@ -49,10 +49,16 @@ class CallbackValueResolver extends AbstractValueResolver implements ArgumentVal yield $data; } + /** + * @param Request $request + * @param ArgumentMetadata $argument + * + * @return string|null + */ private function search(Request $request, ArgumentMetadata $argument): ?string { foreach ($this->requestSchema as $callback) { - if (!$argument->getName() === $callback['type']) { + if ($argument->getType() !== $callback['type']) { continue; } diff --git a/ArgumentResolver/ClientValueResolver.php b/ArgumentResolver/ClientValueResolver.php new file mode 100644 index 0000000..7c21b3a --- /dev/null +++ b/ArgumentResolver/ClientValueResolver.php @@ -0,0 +1,80 @@ +serializer = $serializer; + $this->denormalizer = $denormalizer; + $this->requestSchema = $requestSchema; + } + + /** + * {@inheritdoc} + */ + public function supports(Request $request, ArgumentMetadata $argument): bool + { + return in_array($argument->getType(), $this->requestSchema, true); + } + + /** + * {@inheritdoc} + */ + public function resolve(Request $request, ArgumentMetadata $argument): Generator + { + if (Request::METHOD_GET === $request->getMethod()) { + $dto = $this->handleGetData($request->query->all(), $argument->getType()); + } else { + $dto = $this->handlePostData($request->getContent(), $argument->getType()); + } + + $this->validate($dto); + + yield $dto; + } + + /** + * @param array $data + * @param string $type + * + * @return object + * + * @throws ExceptionInterface + */ + private function handleGetData(array $data, string $type): object + { + return $this->denormalizer->denormalize($data, $type); + } + + /** + * @param string $data + * @param string $type + * + * @return object + */ + private function handlePostData(string $data, string $type): object + { + return $this->serializer->deserialize($data, $type, 'json'); + } +} diff --git a/DependencyInjection/RetailCrmServiceExtension.php b/DependencyInjection/RetailCrmServiceExtension.php index 3df53f0..a4af4e0 100644 --- a/DependencyInjection/RetailCrmServiceExtension.php +++ b/DependencyInjection/RetailCrmServiceExtension.php @@ -3,6 +3,10 @@ namespace RetailCrm\ServiceBundle\DependencyInjection; use RetailCrm\ServiceBundle\ArgumentResolver\CallbackValueResolver; +use RetailCrm\ServiceBundle\ArgumentResolver\ClientValueResolver; +use RetailCrm\ServiceBundle\Response\ErrorJsonResponseFactory; +use RetailCrm\ServiceBundle\Security\CallbackClientAuthenticator; +use RetailCrm\ServiceBundle\Security\FrontApiClientAuthenticator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; @@ -32,5 +36,23 @@ class RetailCrmServiceExtension extends Extension ->setArgument('$requestSchema', '%retail_crm_service.request_schema.callback%') ->addTag('controller.argument_value_resolver', ['priority' => 50]) ->setAutowired(true); + + $container + ->register(ClientValueResolver::class) + ->setArgument('$requestSchema', '%retail_crm_service.request_schema.client%') + ->addTag('controller.argument_value_resolver', ['priority' => 50]) + ->setAutowired(true); + + $container + ->register(ErrorJsonResponseFactory::class) + ->setAutowired(true); + + $container + ->register(CallbackClientAuthenticator::class) + ->setAutowired(true); + + $container + ->register(FrontApiClientAuthenticator::class) + ->setAutowired(true); } } diff --git a/Exceptions/InvalidRequestArgumentException.php b/Exceptions/InvalidRequestArgumentException.php new file mode 100644 index 0000000..7786dd1 --- /dev/null +++ b/Exceptions/InvalidRequestArgumentException.php @@ -0,0 +1,35 @@ +validateErrors = $errors; + } + + public function getValidateErrors(): iterable + { + return $this->validateErrors; + } +} diff --git a/Models/Error.php b/Models/Error.php new file mode 100644 index 0000000..b82b62d --- /dev/null +++ b/Models/Error.php @@ -0,0 +1,21 @@ +serializer = $serializer; + } + + public function create(Error $error, int $statusCode = Response::HTTP_BAD_REQUEST, array $headers = []): Response + { + return JsonResponse::fromJsonString( + $this->serializer->serialize($error, 'json'), + $statusCode, + $headers + ); + } +} diff --git a/Security/AbstractClientAuthenticator.php b/Security/AbstractClientAuthenticator.php new file mode 100644 index 0000000..3e49c90 --- /dev/null +++ b/Security/AbstractClientAuthenticator.php @@ -0,0 +1,61 @@ +errorResponseFactory = $errorResponseFactory; + } + + public function start(Request $request, AuthenticationException $authException = null): Response + { + $error = new Error(); + $error->message = 'Authentication required'; + + return $this->errorResponseFactory->create($error,Response::HTTP_UNAUTHORIZED); + } + + public function getCredentials(Request $request): string + { + return $request->get(static::AUTH_FIELD); + } + + public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface + { + return $userProvider->loadUserByUsername($credentials); + } + + public function checkCredentials($credentials, UserInterface $user): bool + { + return true; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $error = new Error(); + $error->message = $exception->getMessageKey(); + + return $this->errorResponseFactory->create($error,Response::HTTP_FORBIDDEN); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey): ?Response + { + return null; + } +} diff --git a/Security/CallbackClientAuthenticator.php b/Security/CallbackClientAuthenticator.php new file mode 100644 index 0000000..3b61037 --- /dev/null +++ b/Security/CallbackClientAuthenticator.php @@ -0,0 +1,18 @@ +request->has(static::AUTH_FIELD) || $request->query->has(static::AUTH_FIELD); + } + + public function supportsRememberMe(): bool + { + return false; + } +} diff --git a/Security/FrontApiClientAuthenticator.php b/Security/FrontApiClientAuthenticator.php new file mode 100644 index 0000000..19cd8e3 --- /dev/null +++ b/Security/FrontApiClientAuthenticator.php @@ -0,0 +1,35 @@ +security = $security; + } + + public function supports(Request $request): bool + { + if ($this->security->getUser()) { + return false; + } + + return true; + } + + public function supportsRememberMe(): bool + { + return true; + } +} diff --git a/Tests/ArgumentResolver/CallbackValueResolverTest.php b/Tests/ArgumentResolver/CallbackValueResolverTest.php new file mode 100644 index 0000000..86021be --- /dev/null +++ b/Tests/ArgumentResolver/CallbackValueResolverTest.php @@ -0,0 +1,108 @@ +resolver = new CallbackValueResolver( + $serializer, + Validation::createValidatorBuilder() + ->enableAnnotationMapping() + ->getValidator(), + [ + [ + 'type' => RequestDto::class, + 'params' => ['request_parameter'] + ] + ] + ); + } + + public function testSupports(): void + { + $argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null); + $request = new Request( + [], + ['request_parameter' => json_encode(['param' => 'parameter'], JSON_THROW_ON_ERROR)], + [], + [], + [], + ['REQUEST_METHOD' => Request::METHOD_POST] + ); + + $result = $this->resolver->supports($request, $argument); + + static::assertTrue($result); + } + + public function testNotSupports(): void + { + $argument = new ArgumentMetadata('RequestDto', 'NotFoundRequestDto', false, false, null); + $request = new Request( + [], + ['request_parameter' => json_encode(['param' => 'parameter'], JSON_THROW_ON_ERROR)], + [], + [], + [], + ['REQUEST_METHOD' => Request::METHOD_POST] + ); + + $result = $this->resolver->supports($request, $argument); + + static::assertFalse($result); + } + + public function testResolve(): void + { + $argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null); + $request = new Request( + [], + ['request_parameter' => json_encode(['param' => 'parameter'], JSON_THROW_ON_ERROR)], + [], + [], + [], + ['REQUEST_METHOD' => Request::METHOD_POST] + ); + + $result = $this->resolver->resolve($request, $argument); + + static::assertInstanceOf(Generator::class, $result); + static::assertInstanceOf(RequestDto::class, $result->current()); + static::assertEquals('parameter', $result->current()->param); + } + + public function testResolveFailure(): void + { + $argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null); + $request = new Request( + [], + ['request_parameter' => json_encode(['param' => null], JSON_THROW_ON_ERROR)], + [], + [], + [], + ['REQUEST_METHOD' => Request::METHOD_POST] + ); + + $this->expectException(InvalidRequestArgumentException::class); + + $result = $this->resolver->resolve($request, $argument); + $result->current(); + } +} diff --git a/Tests/ArgumentResolver/ClientValueResolverTest.php b/Tests/ArgumentResolver/ClientValueResolverTest.php new file mode 100644 index 0000000..91a28f1 --- /dev/null +++ b/Tests/ArgumentResolver/ClientValueResolverTest.php @@ -0,0 +1,131 @@ +resolver = new ClientValueResolver( + Validation::createValidatorBuilder() + ->enableAnnotationMapping() + ->getValidator(), + $serializer, + $serializer, + [ + RequestDto::class + ] + ); + } + + public function testSupports(): void + { + $argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null); + $request = new Request(); + + $result = $this->resolver->supports($request, $argument); + + static::assertTrue($result); + } + + public function testNotSupports(): void + { + $argument = new ArgumentMetadata('RequestDto', 'NotFoundRequestDto', false, false, null); + $request = new Request(); + + $result = $this->resolver->supports($request, $argument); + + static::assertFalse($result); + } + + public function testResolvePost(): void + { + $argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null); + $request = new Request( + [], + [], + [], + [], + [], + ['REQUEST_METHOD' => Request::METHOD_POST], + json_encode(['param' => 'parameter'], JSON_THROW_ON_ERROR) + ); + + $result = $this->resolver->resolve($request, $argument); + + static::assertInstanceOf(Generator::class, $result); + static::assertInstanceOf(RequestDto::class, $result->current()); + static::assertEquals('parameter', $result->current()->param); + } + + public function testResolvePostFailure(): void + { + $argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null); + $request = new Request( + [], + [], + [], + [], + [], + ['REQUEST_METHOD' => Request::METHOD_POST], + json_encode(['param' => null], JSON_THROW_ON_ERROR) + ); + + $this->expectException(InvalidRequestArgumentException::class); + + $result = $this->resolver->resolve($request, $argument); + $result->current(); + } + + public function testResolveGet(): void + { + $argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null); + $request = new Request( + ['param' => 'parameter'], + [], + [], + [], + [], + [] + ); + + $result = $this->resolver->resolve($request, $argument); + + static::assertInstanceOf(Generator::class, $result); + static::assertInstanceOf(RequestDto::class, $result->current()); + static::assertEquals('parameter', $result->current()->param); + } + + public function testResolveGetFailure(): void + { + $argument = new ArgumentMetadata('RequestDto', RequestDto::class, false, false, null); + $request = new Request( + ['param' => null], + [], + [], + [], + [], + [] + ); + + $this->expectException(InvalidRequestArgumentException::class); + + $result = $this->resolver->resolve($request, $argument); + $result->current(); + } +} diff --git a/Tests/DataFixtures/RequestDto.php b/Tests/DataFixtures/RequestDto.php new file mode 100644 index 0000000..468f9a2 --- /dev/null +++ b/Tests/DataFixtures/RequestDto.php @@ -0,0 +1,14 @@ +createMock(ErrorJsonResponseFactory::class); + $errorResponseFactory + ->expects(static::once()) + ->method('create') + ->willReturn( + new JsonResponse(['message' => 'Authentication required'], Response::HTTP_UNAUTHORIZED) + ); + + $auth = new CallbackClientAuthenticator($errorResponseFactory); + $result = $auth->start(new Request(), new AuthenticationException()); + + static::assertInstanceOf(JsonResponse::class, $result); + static::assertEquals(Response::HTTP_UNAUTHORIZED, $result->getStatusCode()); + } + + public function testGetCredentials(): void + { + $errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class); + + $auth = new CallbackClientAuthenticator($errorResponseFactory); + $result = $auth->getCredentials(new Request([], [CallbackClientAuthenticator::AUTH_FIELD => '123'])); + + static::assertEquals('123', $result); + + $result = $auth->getCredentials(new Request([CallbackClientAuthenticator::AUTH_FIELD => '123'])); + + static::assertEquals('123', $result); + } + + public function testCheckCredentials(): void + { + $errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class); + + $user = new class implements UserInterface { + public function getRoles(): array + { + return ["USER"]; + } + + public function getPassword(): string + { + return "123"; + } + + public function getSalt(): string + { + return "salt"; + } + + public function getUsername(): string + { + return "user"; + } + + public function eraseCredentials(): void + { + } + }; + + $auth = new CallbackClientAuthenticator($errorResponseFactory); + $result = $auth->checkCredentials(new Request(), $user); + + static::assertTrue($result); + } + + public function testOnAuthenticationFailure(): void + { + $errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class); + $errorResponseFactory + ->expects(static::once()) + ->method('create') + ->willReturn( + new JsonResponse( + ['message' => 'An authentication exception occurred.'], + Response::HTTP_FORBIDDEN + ) + ); + + $auth = new CallbackClientAuthenticator($errorResponseFactory); + $result = $auth->start(new Request(), new AuthenticationException()); + + static::assertInstanceOf(JsonResponse::class, $result); + static::assertEquals(Response::HTTP_FORBIDDEN, $result->getStatusCode()); + } + + public function testSupports(): void + { + $errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class); + + $auth = new CallbackClientAuthenticator($errorResponseFactory); + $result = $auth->supports(new Request([], [CallbackClientAuthenticator::AUTH_FIELD => '123'])); + + static::assertTrue($result); + + $result = $auth->supports(new Request([CallbackClientAuthenticator::AUTH_FIELD => '123'])); + + static::assertTrue($result); + + $result = $auth->supports(new Request()); + + static::assertFalse($result); + } + + public function testSupportsRememberMe(): void + { + $errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class); + + $auth = new CallbackClientAuthenticator($errorResponseFactory); + $result = $auth->supportsRememberMe(); + + static::assertFalse($result); + } +} diff --git a/Tests/Security/FrontApiClientAuthenticatorTest.php b/Tests/Security/FrontApiClientAuthenticatorTest.php new file mode 100644 index 0000000..80d8352 --- /dev/null +++ b/Tests/Security/FrontApiClientAuthenticatorTest.php @@ -0,0 +1,118 @@ +createMock(ErrorJsonResponseFactory::class); + $errorResponseFactory + ->expects(static::once()) + ->method('create') + ->willReturn( + new JsonResponse(['message' => 'Authentication required'], Response::HTTP_UNAUTHORIZED) + ); + $security = $this->createMock(Security::class); + + $auth = new FrontApiClientAuthenticator($errorResponseFactory, $security); + $result = $auth->start(new Request(), new AuthenticationException()); + + static::assertInstanceOf(JsonResponse::class, $result); + static::assertEquals(Response::HTTP_UNAUTHORIZED, $result->getStatusCode()); + } + + public function testGetCredentials(): void + { + $errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class); + $security = $this->createMock(Security::class); + + $auth = new FrontApiClientAuthenticator($errorResponseFactory, $security); + $result = $auth->getCredentials(new Request([], [CallbackClientAuthenticator::AUTH_FIELD => '123'])); + + static::assertEquals('123', $result); + + $result = $auth->getCredentials(new Request([CallbackClientAuthenticator::AUTH_FIELD => '123'])); + + static::assertEquals('123', $result); + } + + public function testCheckCredentials(): void + { + $errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class); + $security = $this->createMock(Security::class); + + $auth = new FrontApiClientAuthenticator($errorResponseFactory, $security); + $result = $auth->checkCredentials(new Request(), new User()); + + static::assertTrue($result); + } + + public function testOnAuthenticationFailure(): void + { + $errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class); + $errorResponseFactory + ->expects(static::once()) + ->method('create') + ->willReturn( + new JsonResponse( + ['message' => 'An authentication exception occurred.'], + Response::HTTP_FORBIDDEN + ) + ); + $security = $this->createMock(Security::class); + + $auth = new FrontApiClientAuthenticator($errorResponseFactory, $security); + $result = $auth->start(new Request(), new AuthenticationException()); + + static::assertInstanceOf(JsonResponse::class, $result); + static::assertEquals(Response::HTTP_FORBIDDEN, $result->getStatusCode()); + } + + public function testSupportsFalse(): void + { + $errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class); + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn(new User()); + + $auth = new FrontApiClientAuthenticator($errorResponseFactory, $security); + $result = $auth->supports(new Request()); + + static::assertFalse($result); + } + + public function testSupportsTrue(): void + { + $errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class); + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn(null); + + $auth = new FrontApiClientAuthenticator($errorResponseFactory, $security); + $result = $auth->supports(new Request()); + + static::assertTrue($result); + } + + public function testSupportsRememberMe(): void + { + $errorResponseFactory = $this->createMock(ErrorJsonResponseFactory::class); + $security = $this->createMock(Security::class); + + $auth = new FrontApiClientAuthenticator($errorResponseFactory, $security); + $result = $auth->supportsRememberMe(); + + static::assertTrue($result); + } +} diff --git a/composer.json b/composer.json index a0d6499..f4500bc 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ "symfony/framework-bundle": "^4.0|^5.0", "symfony/serializer": "^5.2", "symfony/http-kernel": "^4.0|^5.0", - "symfony/validator": "^4.0|^5.0" + "symfony/validator": "^4.0|^5.0", + "symfony/security-guard": "^4.0|^5.0" }, "autoload": { "psr-4": { @@ -31,7 +32,10 @@ } }, "require-dev": { - "phpunit/phpunit": "^8.0 || ^9.0" + "ext-json": "*", + "phpunit/phpunit": "^8.0 || ^9.0", + "doctrine/annotations": "^1.11", + "doctrine/cache": "^1.10" }, "scripts": { "tests": "./vendor/bin/phpunit -c phpunit.xml.dist"