From 366dcbddfa17aac2c69fe578f5af5511bf7e26ee Mon Sep 17 00:00:00 2001 From: "n.gnato" Date: Wed, 27 Jan 2021 13:41:55 +0300 Subject: [PATCH 1/4] Add git to docker image for install composer packages from source --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 0a6dfd4..9f6ac5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ FROM php:8-cli RUN apt-get update \ && apt-get install -y \ + git \ libzip-dev \ unzip \ libicu-dev From c1ce0e1e9823039d7587b9e02f7148b427f8c983 Mon Sep 17 00:00:00 2001 From: "n.gnato" Date: Wed, 27 Jan 2021 13:43:54 +0300 Subject: [PATCH 2/4] Add CacheableDispatcherFactoryProxy --- CHANGELOG.md | 1 + composer.json | 3 +- .../CacheableDispatcherFactoryProxy.php | 33 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 src/FreeElephants/JsonApiToolkit/Routing/FastRoute/CacheableDispatcherFactoryProxy.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b969bfc..3743094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Data Transfer Object classes generation from swagger spec +- CacheableDispatcherFactoryProxy ## [0.0.13] - 2021-01-27 ### Added diff --git a/composer.json b/composer.json index d5ab961..ee9c3d2 100644 --- a/composer.json +++ b/composer.json @@ -22,8 +22,9 @@ "neomerx/json-api": "^4.0", "nette/php-generator": "^3.4", "nikic/fast-route": "^1.3", - "psr/http-message": "^1.0", + "psr/cache": "^1.0", "psr/http-factory": "^1.0", + "psr/http-message": "^1.0", "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", "rakit/validation": "^1.2", diff --git a/src/FreeElephants/JsonApiToolkit/Routing/FastRoute/CacheableDispatcherFactoryProxy.php b/src/FreeElephants/JsonApiToolkit/Routing/FastRoute/CacheableDispatcherFactoryProxy.php new file mode 100644 index 0000000..48f5584 --- /dev/null +++ b/src/FreeElephants/JsonApiToolkit/Routing/FastRoute/CacheableDispatcherFactoryProxy.php @@ -0,0 +1,33 @@ +dispatcherFactory = $dispatcherFactory; + $this->cacheItemPool = $cacheItemPool; + } + + public function buildDispatcher(string $openApiDocumentSource): Dispatcher + { + $key = md5_file($openApiDocumentSource); + $cacheItem = $this->cacheItemPool->getItem($key); + if ($cacheItem->isHit()) { + $dispatcher = $cacheItem->get(); + } else { + $dispatcher = $this->dispatcherFactory->buildDispatcher($openApiDocumentSource); + $cacheItem->set($dispatcher); + $this->cacheItemPool->save($cacheItem); + } + + return $dispatcher; + } +} From 00e2de1da9bbe0dc463604d91f1dbbbd2517cc83 Mon Sep 17 00:00:00 2001 From: "n.gnato" Date: Wed, 27 Jan 2021 15:02:18 +0300 Subject: [PATCH 3/4] Add createRelationshipResponse method --- CHANGELOG.md | 1 + .../JsonApiToolkit/Psr/JsonApiResponseFactory.php | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3743094..01fef1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Data Transfer Object classes generation from swagger spec - CacheableDispatcherFactoryProxy +- JsonApiResponseFactory::createRelationshipResponse() ## [0.0.13] - 2021-01-27 ### Added diff --git a/src/FreeElephants/JsonApiToolkit/Psr/JsonApiResponseFactory.php b/src/FreeElephants/JsonApiToolkit/Psr/JsonApiResponseFactory.php index 042c33b..6dccdf6 100644 --- a/src/FreeElephants/JsonApiToolkit/Psr/JsonApiResponseFactory.php +++ b/src/FreeElephants/JsonApiToolkit/Psr/JsonApiResponseFactory.php @@ -50,6 +50,17 @@ public function createResponse($data, ServerRequestInterface $request = null): R return $response; } + public function createRelationshipResponse($data): ResponseInterface + { + $content = $this->encoder->encodeIdentifiers($data); + + $response = $this->createPsrResponse(); + $response->getBody()->write($content); + $response->getBody()->rewind(); + + return $response; + } + public function createErrorResponse(ErrorCollection $errors, int $status): ResponseInterface { $response = $this->createPsrResponse($status); From 4f7695abd38ac516b16b75e1a911473df26b39bd Mon Sep 17 00:00:00 2001 From: "n.gnato" Date: Wed, 27 Jan 2021 15:11:42 +0300 Subject: [PATCH 4/4] Add rate limit middleware --- CHANGELOG.md | 1 + .../Middleware/RateLimitMiddleware.php | 65 +++++++++++++++++ ...ositiveAttributeSkipRateLimitingSolver.php | 20 ++++++ .../JsonApiToolkit/RateLimiter/RateConfig.php | 25 +++++++ .../RateLimiter/RateLimiterInterface.php | 11 +++ .../RateLimiter/RedisRateLimiter.php | 52 ++++++++++++++ .../RateLimiter/SkipRateLimitingSolver.php | 10 +++ .../CompositeRequestIdentityResolver.php | 27 +++++++ .../IpRequestIdentityResolver.php | 23 ++++++ .../RequestIdentityResolverInterface.php | 15 ++++ .../UnresolvableRequestIdentityException.php | 7 ++ .../Middleware/RateLimitMiddlewareTest.php | 72 +++++++++++++++++++ 12 files changed, 328 insertions(+) create mode 100644 src/FreeElephants/JsonApiToolkit/Middleware/RateLimitMiddleware.php create mode 100644 src/FreeElephants/JsonApiToolkit/RateLimiter/OnPositiveAttributeSkipRateLimitingSolver.php create mode 100644 src/FreeElephants/JsonApiToolkit/RateLimiter/RateConfig.php create mode 100644 src/FreeElephants/JsonApiToolkit/RateLimiter/RateLimiterInterface.php create mode 100644 src/FreeElephants/JsonApiToolkit/RateLimiter/RedisRateLimiter.php create mode 100644 src/FreeElephants/JsonApiToolkit/RateLimiter/SkipRateLimitingSolver.php create mode 100644 src/FreeElephants/JsonApiToolkit/RequestIdentityResolver/CompositeRequestIdentityResolver.php create mode 100644 src/FreeElephants/JsonApiToolkit/RequestIdentityResolver/IpRequestIdentityResolver.php create mode 100644 src/FreeElephants/JsonApiToolkit/RequestIdentityResolver/RequestIdentityResolverInterface.php create mode 100644 src/FreeElephants/JsonApiToolkit/RequestIdentityResolver/UnresolvableRequestIdentityException.php create mode 100644 tests/FreeElephants/JsonApiToolkit/Middleware/RateLimitMiddlewareTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 01fef1a..bb5a55d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Data Transfer Object classes generation from swagger spec - CacheableDispatcherFactoryProxy - JsonApiResponseFactory::createRelationshipResponse() +- RateLimitMiddleware and related packages ## [0.0.13] - 2021-01-27 ### Added diff --git a/src/FreeElephants/JsonApiToolkit/Middleware/RateLimitMiddleware.php b/src/FreeElephants/JsonApiToolkit/Middleware/RateLimitMiddleware.php new file mode 100644 index 0000000..33e781a --- /dev/null +++ b/src/FreeElephants/JsonApiToolkit/Middleware/RateLimitMiddleware.php @@ -0,0 +1,65 @@ +responseFactory = $jsonApiResponseFactory; + $this->identityResolver = $identityResolver; + $this->rateConfig = $rateConfig; + $this->rateLimiter = $rateLimiter; + $this->skipRateLimitingSolver = $skipRateLimitingSolver; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (!$this->skipRateLimitingSolver->isShouldSkip($request)) { + $remaining = $this->rateLimiter->limit( + $this->identityResolver->resolve($request), + $this->rateConfig + ); + } else { + $remaining = 1; + } + + if ($remaining <= 0) { + return $this->responseFactory->createSingleErrorResponse('Rate Limit Exceed', 429, $request); + } + + return $this->withHeaders($handler->handle($request), $remaining); + } + + private function withHeaders(ResponseInterface $response, int $remaining): ResponseInterface + { + return $response->withHeader(self::HEADER_LIMIT, $this->rateConfig->getLimitCount()) + ->withHeader(self::HEADER_TTL, $this->rateConfig->getTtl()) + ->withHeader(self::HEADER_REMAINING, $remaining); + } +} diff --git a/src/FreeElephants/JsonApiToolkit/RateLimiter/OnPositiveAttributeSkipRateLimitingSolver.php b/src/FreeElephants/JsonApiToolkit/RateLimiter/OnPositiveAttributeSkipRateLimitingSolver.php new file mode 100644 index 0000000..7b6d06e --- /dev/null +++ b/src/FreeElephants/JsonApiToolkit/RateLimiter/OnPositiveAttributeSkipRateLimitingSolver.php @@ -0,0 +1,20 @@ +attributeName = $attributeName; + } + + public function isShouldSkip(ServerRequestInterface $request): bool + { + return $request->getAttribute($this->attributeName, false); + } +} diff --git a/src/FreeElephants/JsonApiToolkit/RateLimiter/RateConfig.php b/src/FreeElephants/JsonApiToolkit/RateLimiter/RateConfig.php new file mode 100644 index 0000000..ca60da6 --- /dev/null +++ b/src/FreeElephants/JsonApiToolkit/RateLimiter/RateConfig.php @@ -0,0 +1,25 @@ +limitCount = $limitCount; + $this->Ttl = $Ttl; + } + + public function getLimitCount(): int + { + return $this->limitCount; + } + + public function getTtl(): int + { + return $this->Ttl; + } +} diff --git a/src/FreeElephants/JsonApiToolkit/RateLimiter/RateLimiterInterface.php b/src/FreeElephants/JsonApiToolkit/RateLimiter/RateLimiterInterface.php new file mode 100644 index 0000000..1dbc2a5 --- /dev/null +++ b/src/FreeElephants/JsonApiToolkit/RateLimiter/RateLimiterInterface.php @@ -0,0 +1,11 @@ +redis = $redis; + } + + public function limit(string $identity, RateConfig $rateConfig): int + { + $this->redis->set($this->genKey($identity, true), 0, ['nx', 'ex' => $rateConfig->getTtl()]); + + return $rateConfig->getLimitCount() - $this->getValue($identity); + } + + private function genKey(string $identity, $addTimestamp = false) + { + $result = self::KEY . $identity; + + if ($addTimestamp) { + $result .= ':' . microtime(true); + } else { + $result .= ':*'; + } + + return $result; + } + + private function getValue(string $identity): float + { + $count = 0; + $iterator = null; + + while ( + ($keys = $this->redis->scan( + $iterator, + $this->genKey($identity) + )) !== false + ) { + $count += count($keys); + } + + return $count; + } +} diff --git a/src/FreeElephants/JsonApiToolkit/RateLimiter/SkipRateLimitingSolver.php b/src/FreeElephants/JsonApiToolkit/RateLimiter/SkipRateLimitingSolver.php new file mode 100644 index 0000000..6545db5 --- /dev/null +++ b/src/FreeElephants/JsonApiToolkit/RateLimiter/SkipRateLimitingSolver.php @@ -0,0 +1,10 @@ +resolvers = $resolvers; + } + + public function resolve(ServerRequestInterface $request): string + { + foreach ($this->resolvers as $resolver) { + try { + return $resolver->resolve($request); + } catch (UnresolvableRequestIdentityException $exception) { + continue; + } + } + throw new UnresolvableRequestIdentityException(); + } +} diff --git a/src/FreeElephants/JsonApiToolkit/RequestIdentityResolver/IpRequestIdentityResolver.php b/src/FreeElephants/JsonApiToolkit/RequestIdentityResolver/IpRequestIdentityResolver.php new file mode 100644 index 0000000..8eec7ef --- /dev/null +++ b/src/FreeElephants/JsonApiToolkit/RequestIdentityResolver/IpRequestIdentityResolver.php @@ -0,0 +1,23 @@ +getServerParams(); + + if (array_key_exists('HTTP_CLIENT_IP', $serverParams)) { + return $serverParams['HTTP_CLIENT_IP']; + } + + if (array_key_exists('HTTP_X_FORWARDED_FOR', $serverParams)) { + return $serverParams['HTTP_X_FORWARDED_FOR']; + } + + return $serverParams['REMOTE_ADDR'] ?? '127.0.0.1'; + } +} diff --git a/src/FreeElephants/JsonApiToolkit/RequestIdentityResolver/RequestIdentityResolverInterface.php b/src/FreeElephants/JsonApiToolkit/RequestIdentityResolver/RequestIdentityResolverInterface.php new file mode 100644 index 0000000..e103b9f --- /dev/null +++ b/src/FreeElephants/JsonApiToolkit/RequestIdentityResolver/RequestIdentityResolverInterface.php @@ -0,0 +1,15 @@ +createServerRequest('GET', '/v1/foobars'); + $handler = $this->createRequestHandlerWithAssertions( + function () { + return $this->createResponse(); + } + ); + + $resolver = $this->createMock(RequestIdentityResolverInterface::class); + $jarf = $this->createJsonApiResponseFactory(); + $rateLimiter = $this->createMock(RateLimiterInterface::class); + $rateLimiter->method('limit')->willReturnCallback(fn ($identity, RateConfig $config) => $config->getLimitCount() - 1); + $skipSolver = $this->createMock(SkipRateLimitingSolver::class); + $skipSolver->method('isShouldSkip')->willReturn(false); + + $config = new RateConfig(2, 1); + + $middleware = new RateLimitMiddleware($jarf, $resolver, $config, $rateLimiter, $skipSolver); + + $rateLimiter->expects($this->once())->method('limit'); + $resolver->expects($this->once())->method('resolve'); + + $response = $middleware->process($request, $handler); + + $this->assertEquals( + 1, + (int) $response->getHeader(RateLimitMiddleware::HEADER_REMAINING)[0] + ); + } + + public function testReturnsTooManyRequestsResponse() + { + $request = $this->createServerRequest('GET', '/v1/foobars'); + $handler = $this->createRequestHandlerWithAssertions( + function () { + return $this->createResponse(); + } + ); + + $resolver = $this->createMock(RequestIdentityResolverInterface::class); + $jarf = $this->createJsonApiResponseFactory(); + $rateLimiter = $this->createMock(RateLimiterInterface::class); + $rateLimiter->method('limit')->willReturnCallback(fn ($identity, RateConfig $config) => $config->getLimitCount() - 1); + $skipSolver = $this->createMock(SkipRateLimitingSolver::class); + $skipSolver->method('isShouldSkip')->willReturn(false); + + $config = new RateConfig(0, 1); + + $middleware = new RateLimitMiddleware($jarf, $resolver, $config, $rateLimiter, $skipSolver); + $response = $middleware->process($request, $handler); + + $middleware->process($request, $handler); + + $this->assertEquals( + 429, + $response->getStatusCode(), + ); + } +}