diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..54adb9b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b63daac --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +/bin/ +composer.lock +.phpunit.result.cache +.php_cs.cache diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..ba4206f --- /dev/null +++ b/.php_cs @@ -0,0 +1,11 @@ +getFinder() + ->in([ + __DIR__.'/src', + __DIR__.'/tests' + ]); + +return $config; diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d1ec326 --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +SHELL=bash -o pipefail +SOURCE_DIR = $(shell pwd) +BIN_DIR = ${SOURCE_DIR}/bin +COMPOSER = composer + +define printSection + @printf "\033[36m\n==================================================\n\033[0m" + @printf "\033[36m $1 \033[0m" + @printf "\033[36m\n==================================================\n\033[0m" +endef + +.PHONY: all +all: install quality test + +.PHONY: ci +ci: quality test + +.PHONY: install +install: clean-vendor composer-install + +.PHONY: quality +quality: cs-ci phpstan + +.PHONY: test +test: phpunit + +### DEPENDENCIES ### + +.PHONY: clean-vendor +clean-vendor: + $(call printSection,DEPENDENCIES clean) + rm -rf ${SOURCE_DIR}/vendor + +.PHONY: composer-install +composer-install: ${SOURCE_DIR}/vendor/composer/installed.json + +${SOURCE_DIR}/vendor/composer/installed.json: + $(call printSection,DEPENDENCIES install) + $(COMPOSER) --no-interaction install --ansi --no-progress --prefer-dist + +### TEST ### + +.PHONY: phpunit +phpunit: + $(call printSection,TEST phpunit) + ${BIN_DIR}/phpunit + +### QUALITY ### + +.PHONY: phpstan +phpstan: + $(call printSection,QUALITY phpstan) + ${BIN_DIR}/phpstan analyse --memory-limit=1G + +.PHONY: cs-ci +cs-ci: + $(call printSection,QUALITY php-cs-fixer check) + ${BIN_DIR}/php-cs-fixer fix --ansi --dry-run --using-cache=no --verbose + +.PHONY: cs-fix +cs-fix: + $(call printSection,QUALITY php-cs-fixer fix) + ${BIN_DIR}/php-cs-fixer fix + diff --git a/README.md b/README.md index e69de29..7cc3717 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,86 @@ +# RateLimitBundle +This bundle provides an easy way to protect your project by limiting access to your controllers. + +## Install the bundle +```bash +composer require bedrock/rate-limit-bundle +``` + +Update your _config/bundles.php_ file to add the bundle for all env +```php + ['all' => true], + ... +]; +``` + +### Configure the bundle +Add the _config/packages/bedrock_rate_limit.yaml_ file with the following data. +```yaml +bedrock_rate_limit: + limit: 25 # 1000 requests by default + period: 600 # 60 seconds by default + limit_by_route: true|false # false by default + display_headers: true|false # false by default +``` +By default, the limitation is common to all routes annotated `@RateLimit()`. +For example, if you keep the default configuration and you configure the `@RateLimit()` annotation in 2 routes. Limit will shared between this 2 routes, if user consume all authorized calls on the first route, the second route couldn't be called. +If you swicth `limit_by_route` to true, users will be allowed to reach the limit on each route annotated. + +If you switch `display_headers` to true, 3 headers will be added `x-rate-limit`, `x-rate-limit-hits`, `x-rate-limit-untils` to your responses. This can be usefull to debug your limitations. +`display_headers` is used to display a verbose return if limit is reached. + +### Configure your storage +You must tell Symfony which storage implementation you want to use. + +Update your _config/services.yml_ like this: + +```yaml + ... + Bedrock\Bundle\RateLimitBundle\Storage\RateLimitStorageInterface: '@Bedrock\Bundle\RateLimitBundle\Storage\RateLimitInMemoryStorage' + ... +``` + +By default, only `RateLimitInMemory` is provided. But feel free to create your own by implementing `RateLimitStorageInterface` or `ManuallyResetableRateLimitStorageInterface`. +If your database has a TTL system (like Redis), you can implement only `RateLimitStorageInterface`. Otherwhise you must implement also `ManuallyResetableRateLimitStorageInterface` to manually delete rate limit in your database. + +### Configure your modifiers +Modifiers are a way to customize the rate limit. + +This bundle provides 2 modifiers: +* `HttpMethodRateLimitModifier` limits the requests by `http_method`. +* `RequestAttributeRateLimitModifier` limits the requests by attributes value (taken from the `$request->attributes` Symfony's bag). + +Update your _config/services.yml_ like this: + +```yaml + ... + Bedrock\Bundle\RateLimitBundle\RateLimitModifier\HttpMethodRateLimitModifier: + tags: [ 'rate_limit.modifiers' ] + + Bedrock\Bundle\RateLimitBundle\RateLimitModifier\RequestAttributeRateLimitModifier: + arguments: + $attributeName: 'myRequestAttribute' + tags: [ 'rate_limit.modifiers' ] + + ... +``` + +You can also create your own rate limit modifier by implementing `RateLimitModifierInterface` and tagging your service accordingly. + +### Configure your routes +Add the `@RateLimit()` annotation to your controller methods (by default, the limit will be 1000 requests per minute). +This annotation accepts parameters to customize the rate limit. The following example shows how to limit requests on a route at the rate of 10 requests max every 2 minutes. +:warning: This customization only works if the `limit_by_route` parameter is `true` + +```php +/** +* @RateLimit( +* limit=10, +* period=120 +* ) +*/ +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0064f38 --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "bedrock/rate-limit-bundle", + "type": "symfony-bundle", + "license": "MIT", + "authors": [ + { + "name": "Bedrock", + "email": "opensource@bedrockstreaming.com", + "homepage": "https://tech.bedrockstreaming.com/" + } + ], + "config": { + "bin-dir": "bin", + "vendor-dir": "vendor", + "sort-packages": true + }, + "require": { + "php": "7.4.*", + "ext-json": "*", + "doctrine/annotations": "^1.10.0", + "symfony/dependency-injection": "4.4.*", + "symfony/event-dispatcher": "4.4.*", + "symfony/http-foundation": "4.4.*", + "symfony/http-kernel": "4.4.*", + "symfony/config": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "9.4.*", + "m6web/php-cs-fixer-config": "1.3.*", + "phpstan/phpstan": "0.12.*", + "phpstan/phpstan-phpunit": "0.12.*", + "symfony/var-dumper": "4.4.*" + }, + "autoload": { + "psr-4": { + "Bedrock\\Bundle\\RateLimitBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Bedrock\\Bundle\\RateLimitBundle\\Tests\\": "tests/" + } + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..fa5a7d0 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,12 @@ +includes: + - 'vendor/phpstan/phpstan-phpunit/extension.neon' + - 'vendor/phpstan/phpstan-phpunit/rules.neon' + +parameters: + paths: + - 'src' + - 'tests' + level: 'max' + checkGenericClassInNonGenericObjectType: false + ignoreErrors: + - '#Cannot call method integerNode\(\) on Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface\|null\.#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..deebbde --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + + src + + + + + + + + + + tests + + + diff --git a/src/Annotation/RateLimit.php b/src/Annotation/RateLimit.php new file mode 100644 index 0000000..3d48e39 --- /dev/null +++ b/src/Annotation/RateLimit.php @@ -0,0 +1,48 @@ + $args + */ + public function __construct(array $args = []) + { + $this->limit = $args['limit'] ?? null; + $this->period = $args['period'] ?? null; + } + + public function getLimit(): ?int + { + return $this->limit; + } + + public function setLimit(int $limit): RateLimit + { + $this->limit = $limit; + + return $this; + } + + public function getPeriod(): ?int + { + return $this->period; + } + + public function setPeriod(int $period): RateLimit + { + $this->period = $period; + + return $this; + } +} diff --git a/src/DependencyInjection/BedrockRateLimitExtension.php b/src/DependencyInjection/BedrockRateLimitExtension.php new file mode 100644 index 0000000..4c9c209 --- /dev/null +++ b/src/DependencyInjection/BedrockRateLimitExtension.php @@ -0,0 +1,41 @@ + $configs + */ + public function load(array $configs, ContainerBuilder $container): void + { + $configuration = new Configuration(); + $config = $this->processConfiguration($configuration, $configs); + + $container->setParameter('bedrock_rate_limit.limit', $config['limit']); + $container->setParameter('bedrock_rate_limit.period', $config['period']); + $container->setParameter('bedrock_rate_limit.limit_by_route', $config['limit_by_route']); + $container->setParameter('bedrock_rate_limit.display_headers', $config['display_headers']); + + $loader = new YamlFileLoader( + $container, + new FileLocator(__DIR__.'/../Resources/config') + ); + $loader->load('services.yml'); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..803ba1c --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,40 @@ +getRootNode(); + + $rootNode + ->children() + ->booleanNode('limit_by_route') + ->defaultValue(false) + ->end() + ->integerNode('limit') + ->defaultValue(1000) + ->min(0) + ->end() + ->integerNode('period') + ->defaultValue(60) + ->min(0) + ->end() + ->booleanNode('display_headers') + ->defaultValue(false) + ->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/EventListener/AddRateLimitHeadersListener.php b/src/EventListener/AddRateLimitHeadersListener.php new file mode 100644 index 0000000..e973106 --- /dev/null +++ b/src/EventListener/AddRateLimitHeadersListener.php @@ -0,0 +1,56 @@ +displayHeaders = $displayHeaders; + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$this->displayHeaders) { + return; + } + + if (!$event->isMasterRequest()) { + return; + } + + $request = $event->getRequest(); + if (!$request->attributes->has('_stored_rate_limit')) { + return; + } + + $storedRateLimit = $request->attributes->get('_stored_rate_limit'); + + if (!$storedRateLimit instanceof StoredRateLimit) { + return; + } + + $response = $event->getResponse(); + $response->headers->set('x-rate-limit', (string) $storedRateLimit->getLimit()); + $response->headers->set('x-rate-limit-hits', (string) $storedRateLimit->getHits()); + $response->headers->set('x-rate-limit-until', $storedRateLimit->getValidUntil()->format('c')); + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + ResponseEvent::class => 'onKernelResponse', + ]; + } +} diff --git a/src/EventListener/LimitRateListener.php b/src/EventListener/LimitRateListener.php new file mode 100644 index 0000000..5bfc69e --- /dev/null +++ b/src/EventListener/LimitRateListener.php @@ -0,0 +1,85 @@ +storage = $storage; + $this->displayHeaders = $displayHeaders; + } + + public function onKernelController(ControllerArgumentsEvent $event): void + { + if (!$event->isMasterRequest()) { + return; + } + + $request = $event->getRequest(); + + if (!$request->attributes->has('_rate_limit')) { + return; + } + + $rateLimit = $request->attributes->get('_rate_limit'); + + if (!$rateLimit instanceof RateLimit) { + throw new \InvalidArgumentException(sprintf('Request attribute "_rate_limit" should be of type "%s". "%s" given.', RateLimit::class, \is_object($rateLimit) ? \get_class($rateLimit) : \gettype($rateLimit))); + } + + $storedRateLimit = $this->storage->getStoredRateLimit($rateLimit); + + if (null !== $storedRateLimit + && $storedRateLimit->isOutdated()) { + if ($this->storage instanceof ManuallyResetableRateLimitStorageInterface) { + $this->storage->resetRateLimit($rateLimit); + } + + $storedRateLimit = null; + } + + if (null !== $storedRateLimit && $storedRateLimit->getHits() >= $rateLimit->getLimit()) { + $displayHeaders = $this->displayHeaders; + $event->setController( + static function () use ($displayHeaders, $storedRateLimit) { + return new JsonResponse( + $displayHeaders ? $storedRateLimit->getLimitReachedOutput() : Response::$statusTexts[Response::HTTP_TOO_MANY_REQUESTS], + Response::HTTP_TOO_MANY_REQUESTS + ); + } + ); + } + + if (null === $storedRateLimit) { + $storedRateLimit = $this->storage->storeRateLimit($rateLimit); + } else { + $storedRateLimit = $this->storage->incrementHits($storedRateLimit); + } + + $request->attributes->set('_stored_rate_limit', $storedRateLimit); + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + ControllerArgumentsEvent::class => 'onKernelController', + ]; + } +} diff --git a/src/EventListener/ReadRateLimitAnnotationListener.php b/src/EventListener/ReadRateLimitAnnotationListener.php new file mode 100644 index 0000000..340e7b7 --- /dev/null +++ b/src/EventListener/ReadRateLimitAnnotationListener.php @@ -0,0 +1,102 @@ + */ + private $rateLimitModifiers; + private int $limit; + private int $period; + private bool $limitByRoute; + private ContainerInterface $container; + + /** + * @param RateLimitModifierInterface[] $rateLimitModifiers + */ + public function __construct(ContainerInterface $container, Reader $annotationReader, iterable $rateLimitModifiers, int $limit, int $period, bool $limitByRoute) + { + foreach ($rateLimitModifiers as $rateLimitModifier) { + if (!($rateLimitModifier instanceof RateLimitModifierInterface)) { + throw new \InvalidArgumentException(('$rateLimitModifiers must be instance of '.RateLimitModifierInterface::class)); + } + } + + $this->annotationReader = $annotationReader; + $this->rateLimitModifiers = $rateLimitModifiers; + $this->limit = $limit; + $this->period = $period; + $this->limitByRoute = $limitByRoute; + $this->container = $container; + } + + public function onKernelController(ControllerEvent $event): void + { + $request = $event->getRequest(); + // retrieve controller and method from request + $controllerAttribute = $request->attributes->get('_controller', null); + if (null === $controllerAttribute || !is_string($controllerAttribute)) { + return; + } + $controllerAttributeParts = explode('::', $controllerAttribute); + $controllerName = $controllerAttributeParts[0] ?? ''; + $methodName = $controllerAttributeParts[1] ?? null; + + if (!class_exists($controllerName)) { + // If controller attribute is an alias instead of a class name + $serviceIdAttributeParts = explode(':', $controllerName); + if (null === ($controllerName = $this->container->get($serviceIdAttributeParts[0]))) { + throw new \InvalidArgumentException('Parameter _controller from request : "'.$controllerAttribute.'" do not contains a valid class name'); + } + $methodName = $serviceIdAttributeParts[1] ?? null; + } + $reflection = new \ReflectionClass($controllerName); + $annotation = $this->annotationReader->getMethodAnnotation($reflection->getMethod((string) ($methodName ?? '__invoke')), RateLimitAnnotation::class); + + if (!$annotation instanceof RateLimitAnnotation) { + return; + } + + if ($this->limitByRoute) { + $rateLimit = new RateLimit( + $annotation->getLimit() ?? $this->limit, + $annotation->getPeriod() ?? $this->period + ); + + $rateLimit->varyHashOn('_route', $request->attributes->get('_route')); + } else { + $rateLimit = new RateLimit( + $this->limit, + $this->period + ); + } + + foreach ($this->rateLimitModifiers as $hashKeyVarier) { + if ($hashKeyVarier->support($request)) { + $hashKeyVarier->modifyRateLimit($request, $rateLimit); + } + } + $request->attributes->set('_rate_limit', $rateLimit); + } + + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + ControllerEvent::class => 'onKernelController', + ]; + } +} diff --git a/src/Model/RateLimit.php b/src/Model/RateLimit.php new file mode 100644 index 0000000..db94a2f --- /dev/null +++ b/src/Model/RateLimit.php @@ -0,0 +1,59 @@ + */ + private array $vary = []; + private int $limit; + private int $period; + + public function __construct(int $limit, int $period) + { + if ($limit < 0 || $period < 0) { + throw new \InvalidArgumentException('Limit and period must be > 0'); + } + + $this->limit = $limit; + $this->period = $period; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getPeriod(): int + { + return $this->period; + } + + public function varyHashOn(string $key, string $value): void + { + if (array_key_exists($key, $this->vary)) { + throw new \InvalidArgumentException(sprintf('Key "%s" already exists in "vary" array.', $key)); + } + + $this->vary[$key] = $value; + } + + public function getDiscriminator(): string + { + if (count($this->vary) === 0) { + throw new \InvalidArgumentException('Cannot compute rate limit discriminator with an empty vary.'); + } + + return (string) json_encode($this->vary); + } + + /** + * @return string The current request's hash discriminator on which to calculate the rate limit + */ + public function getHash(): string + { + return md5($this->getDiscriminator()); + } +} diff --git a/src/Model/StoredRateLimit.php b/src/Model/StoredRateLimit.php new file mode 100644 index 0000000..bd6ba44 --- /dev/null +++ b/src/Model/StoredRateLimit.php @@ -0,0 +1,70 @@ +rateLimit = $rateLimit; + $this->hits = $hits; + $this->validUntil = $validUntil; + } + + public function getHash(): string + { + return $this->rateLimit->getHash(); + } + + public function getLimit(): int + { + return $this->rateLimit->getLimit(); + } + + public function getHits(): int + { + return $this->hits; + } + + public function withHits(int $hits): self + { + $clone = clone $this; + $clone->hits = $hits; + + return $clone; + } + + public function isOutdated(): bool + { + return $this->validUntil < \DateTimeImmutable::createFromFormat('U', (string) time()); + } + + public function getValidUntil(): \DateTimeImmutable + { + return $this->validUntil; + } + + /** + * @return array|int|string> + */ + public function getLimitReachedOutput(): array + { + return [ + 'message' => sprintf( + 'Too many requests. Only %d calls allowed every %d seconds.', + $this->getLimit(), + $this->rateLimit->getPeriod() + ), + 'limit' => $this->getLimit(), + 'period' => $this->rateLimit->getPeriod(), + 'until' => $this->getValidUntil()->format('Y-m-d H:i:s'), + 'vary' => $this->rateLimit->getDiscriminator(), + ]; + } +} diff --git a/src/RateLimitBundle.php b/src/RateLimitBundle.php new file mode 100644 index 0000000..9c6742f --- /dev/null +++ b/src/RateLimitBundle.php @@ -0,0 +1,16 @@ +varyHashOn('http_method', $request->getMethod()); + } +} diff --git a/src/RateLimitModifier/RateLimitModifierInterface.php b/src/RateLimitModifier/RateLimitModifierInterface.php new file mode 100644 index 0000000..5bd17d1 --- /dev/null +++ b/src/RateLimitModifier/RateLimitModifierInterface.php @@ -0,0 +1,13 @@ +attributeName = $attributeName; + } + + public function support(Request $request): bool + { + return $request->attributes->has($this->attributeName); + } + + public function modifyRateLimit(Request $request, RateLimit $rateLimit): void + { + $rateLimit->varyHashOn($this->attributeName, $request->attributes->get($this->attributeName)); + } +} diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml new file mode 100644 index 0000000..0db27f2 --- /dev/null +++ b/src/Resources/config/services.yml @@ -0,0 +1,22 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Bedrock\Bundle\RateLimitBundle\: + resource: '../../../src/*' + + Bedrock\Bundle\RateLimitBundle\EventListener\ReadRateLimitAnnotationListener: + arguments: + $limit: '%bedrock_rate_limit.limit%' + $period: '%bedrock_rate_limit.period%' + $limitByRoute: '%bedrock_rate_limit.limit_by_route%' + $rateLimitModifiers: !tagged rate_limit.modifiers + + Bedrock\Bundle\RateLimitBundle\EventListener\LimitRateListener: + arguments: + $displayHeaders: '%bedrock_rate_limit.display_headers%' + + Bedrock\Bundle\RateLimitBundle\EventListener\AddRateLimitHeadersListener: + arguments: + $displayHeaders: '%bedrock_rate_limit.display_headers%' diff --git a/src/Storage/ManuallyResetableRateLimitStorageInterface.php b/src/Storage/ManuallyResetableRateLimitStorageInterface.php new file mode 100644 index 0000000..6e41ddb --- /dev/null +++ b/src/Storage/ManuallyResetableRateLimitStorageInterface.php @@ -0,0 +1,13 @@ + */ + private static $storedRateLimits = []; + + public function getStoredRateLimit(RateLimit $rateLimit): ?StoredRateLimit + { + return self::$storedRateLimits[$rateLimit->getHash()] ?? null; + } + + public function storeRateLimit(RateLimit $rateLimit): StoredRateLimit + { + $validUntil = \DateTimeImmutable::createFromFormat('U', (string) (time() + $rateLimit->getPeriod())); + if ($validUntil === false) { + throw new \RuntimeException('Invalid rate limit period'); + } + + self::$storedRateLimits[$rateLimit->getHash()] = new StoredRateLimit($rateLimit, 1, $validUntil); + + return self::$storedRateLimits[$rateLimit->getHash()]; + } + + public function incrementHits(StoredRateLimit $storedRateLimit): StoredRateLimit + { + self::$storedRateLimits[$storedRateLimit->getHash()] = $storedRateLimit->withHits($storedRateLimit->getHits() + 1); + + return self::$storedRateLimits[$storedRateLimit->getHash()]; + } + + public function resetRateLimit(RateLimit $rateLimit): void + { + if (array_key_exists($rateLimit->getHash(), self::$storedRateLimits)) { + unset(self::$storedRateLimits[$rateLimit->getHash()]); + } + } + + public static function reset(): void + { + self::$storedRateLimits = []; + } +} diff --git a/src/Storage/RateLimitStorageInterface.php b/src/Storage/RateLimitStorageInterface.php new file mode 100644 index 0000000..802a46b --- /dev/null +++ b/src/Storage/RateLimitStorageInterface.php @@ -0,0 +1,24 @@ +onKernelResponse($event = $this->createEvent()); + + $response = $event->getResponse(); + $this->assertFalse($response->headers->has('x-rate-limit')); + $this->assertFalse($response->headers->has('x-rate-limit-hits')); + $this->assertFalse($response->headers->has('x-rate-limit-until')); + } + + public function testItSetsHeadersIfRateLimitProvidedInRequest(): void + { + $addRateLimitHeadersListener = new AddRateLimitHeadersListener(true); + $addRateLimitHeadersListener->onKernelResponse( + $event = $this->createEvent( + new Request( + [], + [], + [ + '_stored_rate_limit' => new StoredRateLimit( + new RateLimit(1000, 60), 4, $validUntil = new \DateTimeImmutable() + ), + ] + ) + ) + ); + + $response = $event->getResponse(); + $this->assertSame(1000, (int) $response->headers->get('x-rate-limit')); + $this->assertSame(4, (int) $response->headers->get('x-rate-limit-hits')); + $this->assertSame($validUntil->format('c'), $response->headers->get('x-rate-limit-until')); + } + + public function testResponseHasNotHeaderIfDisplayHeadersIsDisable(): void + { + $addRateLimitHeadersListener = new AddRateLimitHeadersListener(false); + $addRateLimitHeadersListener->onKernelResponse( + $event = $this->createEvent( + new Request( + [], + [], + [ + '_stored_rate_limit' => new StoredRateLimit( + new RateLimit(1000, 60), 4, $validUntil = new \DateTimeImmutable() + ), + ] + ) + ) + ); + + $response = $event->getResponse(); + $this->assertFalse($response->headers->has('x-rate-limit')); + $this->assertFalse($response->headers->has('x-rate-limit-hits')); + $this->assertFalse($response->headers->has('x-rate-limit-until')); + } + + private function createEvent(Request $request = null): ResponseEvent + { + return new ResponseEvent( + $this->createMock(HttpKernelInterface::class), + $request ?? new Request(), + HttpKernelInterface::MASTER_REQUEST, + new Response() + ); + } +} diff --git a/tests/EventListener/BaseLimitRateListenerTest.php b/tests/EventListener/BaseLimitRateListenerTest.php new file mode 100644 index 0000000..053cc2d --- /dev/null +++ b/tests/EventListener/BaseLimitRateListenerTest.php @@ -0,0 +1,68 @@ +varyHashOn('http_method', 'GET'); + + return $this->createEvent( + new Request( + [], + [], + ['_rate_limit' => $rateLimit] + ) + ); + } + + protected function createEvent(Request $request = null): ControllerArgumentsEvent + { + $controller = new class() { + /** + * @RateLimit() + */ + public function action(): void + { + } + }; + + return new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [$controller, 'action'], + [], + $request ?? new Request(), + HttpKernelInterface::MASTER_REQUEST + ); + } + + /** + * @return StoredRateLimit|MockObject + * + * Mocking this value object is needed otherwise RateLimit\FunctionalTest will fail + * because of a bug from outer space in ClockMock + */ + protected function mockStoredRateLimit(RateLimit $rateLimit, int $hits, \DateTimeImmutable $validUntil) + { + $mock = $this->createMock(StoredRateLimit::class); + $mock->expects($this->any())->method('getHash')->willReturn($rateLimit->getHash()); + $mock->expects($this->any())->method('getLimit')->willReturn($rateLimit->getLimit()); + $mock->expects($this->any())->method('getHits')->willReturn($hits); + $mock->expects($this->any())->method('getValidUntil')->willReturn($validUntil); + $mock->expects($this->any())->method('isOutdated')->willReturn($validUntil < new \DateTimeImmutable()); + + return $mock; + } +} diff --git a/tests/EventListener/LimitRateListenerTest.php b/tests/EventListener/LimitRateListenerTest.php new file mode 100644 index 0000000..2ee5eca --- /dev/null +++ b/tests/EventListener/LimitRateListenerTest.php @@ -0,0 +1,176 @@ +limitRateListener = new LimitRateListener( + $this->storage = $this->createMock(RateLimitStorageInterface::class), + false + ); + } + + public function testItDoesNotCheckRateLimitIfItIsNotInRequest(): void + { + $event = $this->createEvent(); + + $this->storage->expects($this->never())->method('getStoredRateLimit'); + + $this->limitRateListener->onKernelController($event); + + $this->assertFalse($event->getRequest()->attributes->has('_stored_rate_limit')); + } + + public function testItStoresRateLimitIfNoneIsStored(): void + { + $event = $this->createEventWithRateLimitInRequest(); + $rateLimit = $event->getRequest()->attributes->get('_rate_limit'); + + $this->storage->expects($this->once()) + ->method('getStoredRateLimit') + ->with($rateLimit) + ->willReturn(null); + + $this->storage->expects($this->once())->method('storeRateLimit')->with($rateLimit); + + $this->storage->expects($this->never())->method('incrementHits'); + + $oldController = $event->getController(); + $this->limitRateListener->onKernelController($event); + $this->assertSame($oldController, $event->getController()); + $this->assertInstanceOf(StoredRateLimit::class, $event->getRequest()->attributes->get('_stored_rate_limit')); + } + + public function testItResetsAndStoresNewRateLimitIfCurrentOneIsOutdated(): void + { + $event = $this->createEventWithRateLimitInRequest(); + $rateLimit = $event->getRequest()->attributes->get('_rate_limit'); + + $this->storage->expects($this->once()) + ->method('getStoredRateLimit') + ->with($rateLimit) + ->willReturn( + $storedRateLimit = $this->mockStoredRateLimit($rateLimit, 1, new \DateTimeImmutable('1 day ago')) + ); + + $this->storage->expects($this->once())->method('storeRateLimit')->with($rateLimit); + + $this->storage->expects($this->never())->method('incrementHits'); + + $oldController = $event->getController(); + $this->limitRateListener->onKernelController($event); + $this->assertSame($oldController, $event->getController()); + $this->assertInstanceOf(StoredRateLimit::class, $event->getRequest()->attributes->get('_stored_rate_limit')); + } + + public function testItDecreasesLimitIfRateLimitIsValid(): void + { + $event = $this->createEventWithRateLimitInRequest(); + $rateLimit = $event->getRequest()->attributes->get('_rate_limit'); + + $this->storage->expects($this->once()) + ->method('getStoredRateLimit') + ->with($rateLimit) + ->willReturn( + $storedRateLimit = $this->mockStoredRateLimit($rateLimit, 4, new \DateTimeImmutable('+1 day')) + ); + + $this->storage->expects($this->once())->method('incrementHits')->with($storedRateLimit); + + $this->storage->expects($this->never())->method('storeRateLimit'); + + $oldController = $event->getController(); + $this->limitRateListener->onKernelController($event); + $this->assertSame($oldController, $event->getController()); + + $this->assertInstanceOf(StoredRateLimit::class, $event->getRequest()->attributes->get('_stored_rate_limit')); + } + + public function testItSetsABlockingResponseIfLimitIsReached(): void + { + $event = $this->createEventWithRateLimitInRequest(); + $rateLimit = $event->getRequest()->attributes->get('_rate_limit'); + + $this->storage + ->expects($this->once()) + ->method('getStoredRateLimit') + ->willReturn( + $this->mockStoredRateLimit($rateLimit, 1000, new \DateTimeImmutable('+1 day')) + ); + + $this->storage->expects($this->once())->method('incrementHits'); + + $this->storage->expects($this->never())->method('storeRateLimit'); + + $oldController = $event->getController(); + $this->limitRateListener->onKernelController($event); + + $newController = $event->getController(); + $this->assertNotSame($oldController, $newController); + /** @var Response $response */ + $response = $newController(); + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode()); + $this->assertSame('"Too Many Requests"', $response->getContent()); + + $this->assertInstanceOf(StoredRateLimit::class, $event->getRequest()->attributes->get('_stored_rate_limit')); + } + + public function testItSetsABlockingResponseIfLimitIsExceeded(): void + { + $event = $this->createEventWithRateLimitInRequest(); + $rateLimit = $event->getRequest()->attributes->get('_rate_limit'); + + $this->storage->expects($this->once())->method('getStoredRateLimit')->willReturn( + $this->mockStoredRateLimit($rateLimit, 1001, new \DateTimeImmutable('+1 day')) + ); + + $this->limitRateListener->onKernelController($event); + /** @var Response $response */ + $response = ($event->getController())(); + $this->assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode()); + $this->assertSame('"Too Many Requests"', $response->getContent()); + $this->assertSame('application/json', $response->headers->get('content-type')); + } + + public function testTooManyRequestResponseHasCompleteDataIfDisplayHeadersIsEnable(): void + { + // Override $this->limitRateListener to displayHeaders + $this->limitRateListener = new LimitRateListener( + $this->storage = $this->createMock(RateLimitStorageInterface::class), + true + ); + + $event = $this->createEventWithRateLimitInRequest(); + /** @var RateLimit $rateLimit */ + $rateLimit = $event->getRequest()->attributes->get('_rate_limit'); + + $this->storage->expects($this->once())->method('getStoredRateLimit')->willReturn( + $storedRateLimit = $this->mockStoredRateLimit($rateLimit, 1001, new \DateTimeImmutable('+1 day')) + ); + + $storedRateLimit + ->expects($this->once()) + ->method('getLimitReachedOutput'); + + $this->limitRateListener->onKernelController($event); + /** @var Response $response */ + $response = ($event->getController())(); + $this->assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode()); + $this->assertSame('application/json', $response->headers->get('content-type')); + } +} diff --git a/tests/EventListener/LimitRateListenerWithManuallyResetableRateLimitStorageInterfaceTest.php b/tests/EventListener/LimitRateListenerWithManuallyResetableRateLimitStorageInterfaceTest.php new file mode 100644 index 0000000..e95420f --- /dev/null +++ b/tests/EventListener/LimitRateListenerWithManuallyResetableRateLimitStorageInterfaceTest.php @@ -0,0 +1,47 @@ +limitRateListener = new LimitRateListener( + $this->storage = $this->createMock(ManuallyResetableRateLimitStorageInterface::class), + false + ); + } + + public function testItResetsAndStoresNewRateLimitIfCurrentOneIsOutdated(): void + { + $event = $this->createEventWithRateLimitInRequest(); + $rateLimit = $event->getRequest()->attributes->get('_rate_limit'); + + $this->storage->expects($this->once()) + ->method('getStoredRateLimit') + ->with($rateLimit) + ->willReturn( + $storedRateLimit = $this->mockStoredRateLimit($rateLimit, 1, new \DateTimeImmutable('1 day ago')) + ); + + $this->storage->expects($this->once())->method('resetRateLimit')->with($rateLimit); + $this->storage->expects($this->once())->method('storeRateLimit')->with($rateLimit); + + $this->storage->expects($this->never())->method('incrementHits'); + + $oldController = $event->getController(); + $this->limitRateListener->onKernelController($event); + $this->assertSame($oldController, $event->getController()); + $this->assertInstanceOf(StoredRateLimit::class, $event->getRequest()->attributes->get('_stored_rate_limit')); + } +} diff --git a/tests/EventListener/ReadRateLimitAnnotationListenerTest.php b/tests/EventListener/ReadRateLimitAnnotationListenerTest.php new file mode 100644 index 0000000..d4e19e6 --- /dev/null +++ b/tests/EventListener/ReadRateLimitAnnotationListenerTest.php @@ -0,0 +1,209 @@ + */ + private $rateLimitModifiers; + private int $limitDefaultValue = 1000; + private int $periodDefaultValue = 60; + /** @var MockObject|ContainerInterface */ + private $container; + + public function createReadRateLimitAnnotationListerner(bool $defaultLimitByRouteValue = false): void + { + $this->readRateLimitAnnotationListener = new ReadRateLimitAnnotationListener( + $this->container = $this->createMock(ContainerInterface::class), + $this->annotationReader = $this->createMock(AnnotationReader::class), + $this->rateLimitModifiers = [ + $this->createMock(RateLimitModifierInterface::class), + $this->createMock(RateLimitModifierInterface::class), + ], + $this->limitDefaultValue, + $this->periodDefaultValue, + $defaultLimitByRouteValue + ); + } + + public function testItDoesNotSetRateLimitIfNoAnnotationProvided(): void + { + $this->createReadRateLimitAnnotationListerner(); + $event = $this->createEvent(); + + $this->container->expects($this->never()) + ->method('has'); + + $this->annotationReader->expects($this->once())->method('getMethodAnnotation')->willReturn(null); + + $this->rateLimitModifiers[0]->expects($this->never())->method('support'); + $this->rateLimitModifiers[1]->expects($this->never())->method('support'); + + $this->readRateLimitAnnotationListener->onKernelController($event); + $this->assertFalse($event->getRequest()->attributes->has('_rate_limit')); + } + + public function testItSetRateLimitIfNoAnnotationProvidedAndServiceAliasIsUsed(): void + { + $this->createReadRateLimitAnnotationListerner(); + + $this->container->expects($this->once()) + ->method('get') + ->willReturn(new FakeInvokableClassWithDefaultRateLimit()); + + $event = $this->createEvent(null, true); + + $this->annotationReader->expects($this->once())->method('getMethodAnnotation')->willReturn(null); + + $this->rateLimitModifiers[0]->expects($this->never())->method('support'); + $this->rateLimitModifiers[1]->expects($this->never())->method('support'); + + $this->readRateLimitAnnotationListener->onKernelController($event); + $this->assertFalse($event->getRequest()->attributes->has('_rate_limit')); + } + + public function testItSetsRateLimitIfAnnotationProvidedWithDefaultValue(): void + { + $this->createReadRateLimitAnnotationListerner(); + $request = $this->createMock(Request::class); + $request->attributes = new ParameterBag(); + $event = $this->createEvent($request); + + $this->container->expects($this->never()) + ->method('has'); + + $this->annotationReader->expects($this->once())->method('getMethodAnnotation')->willReturn(new RateLimitAnnotation()); + + $this->rateLimitModifiers[0]->expects($this->once())->method('support')->willReturn(true); + $rateLimit = new RateLimit($this->limitDefaultValue, $this->periodDefaultValue); + $this->rateLimitModifiers[0]->expects($this->once())->method('modifyRateLimit')->with($request, $rateLimit); + + $this->rateLimitModifiers[1]->expects($this->once())->method('support')->willReturn(false); + $this->rateLimitModifiers[1]->expects($this->never())->method('modifyRateLimit'); + + $this->readRateLimitAnnotationListener->onKernelController($event); + $this->assertTrue($event->getRequest()->attributes->has('_rate_limit')); + + $this->assertEquals( + $rateLimit, + $event->getRequest()->attributes->get('_rate_limit') + ); + } + + /** + * @dataProvider rateLimitConfigurationDataProvider + */ + public function testItSetsRateLimitIfAnnotationProvidedWithCustomValue(bool $isLimitByRouteEnbaled): void + { + $this->createReadRateLimitAnnotationListerner($isLimitByRouteEnbaled); + + $request = $this->createMock(Request::class); + $request->attributes = new ParameterBag(['_route' => 'a-random-route']); + $event = $this->createEventWithAnnotation($request); + + $this->container->expects($this->never()) + ->method('has'); + + $this->annotationReader->expects($this->once())->method('getMethodAnnotation')->willReturn(new RateLimitAnnotation(['limit' => 10, 'period' => 5])); + + $this->rateLimitModifiers[0]->expects($this->once())->method('support')->willReturn(true); + if ($isLimitByRouteEnbaled) { + $rateLimit = new RateLimit(10, 5); + $rateLimit->varyHashOn('_route', 'a-random-route'); + } else { + $rateLimit = new RateLimit($this->limitDefaultValue, $this->periodDefaultValue); + } + $this->rateLimitModifiers[0]->expects($this->once())->method('modifyRateLimit')->with($request, $rateLimit); + + $this->rateLimitModifiers[1]->expects($this->once())->method('support')->willReturn(false); + $this->rateLimitModifiers[1]->expects($this->never())->method('modifyRateLimit'); + + $this->readRateLimitAnnotationListener->onKernelController($event); + $this->assertTrue($event->getRequest()->attributes->has('_rate_limit')); + + $this->assertEquals( + $rateLimit, + $event->getRequest()->attributes->get('_rate_limit') + ); + } + + /** + * @return \Generator> + */ + public function rateLimitConfigurationDataProvider(): \Generator + { + yield 'rate_limit_by_route_is_disabled' => [ + false, + ]; + + yield 'rate_limit_by_route_is_enabled' => [ + true, + ]; + } + + protected function createEvent(Request $request = null, bool $useAlias = false): ControllerEvent + { + $request = $request ?? new Request(); + $request->attributes->set('_controller', $useAlias ? 'fake.invokable.class:__invoke' : FakeInvokableClassWithDefaultRateLimit::class); + + return new ControllerEvent( + $this->createMock(HttpKernelInterface::class), + new FakeInvokableClassWithDefaultRateLimit(), + $request ?? new Request(), + HttpKernelInterface::MASTER_REQUEST + ); + } + + public function createEventWithAnnotation(Request $request): ControllerEvent + { + $request->attributes->set('_controller', FakeClassWithRateLimit::class.'::action'); + + return new ControllerEvent( + $this->createMock(HttpKernelInterface::class), + [new FakeClassWithRateLimit(), 'action'], + $request, + HttpKernelInterface::MASTER_REQUEST + ); + } +} + +class FakeInvokableClassWithDefaultRateLimit +{ + /** + * @RateLimit() + */ + public function __invoke(): void + { + } +} + +class FakeClassWithRateLimit +{ + /** + * @RateLimite( + * limit=10, + * period=5 + * ) + */ + public function action(): void + { + } +} diff --git a/tests/Model/RateLimitTest.php b/tests/Model/RateLimitTest.php new file mode 100644 index 0000000..95926a9 --- /dev/null +++ b/tests/Model/RateLimitTest.php @@ -0,0 +1,65 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Limit and period must be > 0'); + + new RateLimit(-10, 100); + } + + public function testCreateARateLimitWithNegativePeriodThrowAnException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Limit and period must be > 0'); + + new RateLimit(10, -100); + } + + public function testItThrowsExceptionWhenComputingDiscriminatorAndNoVaryProvided(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot compute rate limit discriminator with an empty vary.'); + + $rateLimit = new RateLimit(1000, 60); + $rateLimit->getDiscriminator(); + } + + public function testItComputesDiscriminator(): void + { + $rateLimit = new RateLimit(1000, 60); + $rateLimit->varyHashOn('method', 'GET'); + $this->assertSame('{"method":"GET"}', $rateLimit->getDiscriminator()); + + $rateLimit->varyHashOn('url', 'http://url'); + $this->assertSame('{"method":"GET","url":"http:\/\/url"}', $rateLimit->getDiscriminator()); + } + + public function testItThrowsExceptionWhenComputingHashKeyAndNoVaryProvided(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot compute rate limit discriminator with an empty vary.'); + + $rateLimit = new RateLimit(1000, 60); + $rateLimit->getHash(); + } + + public function testItComputesHash(): void + { + $rateLimit = new RateLimit(1000, 60); + $rateLimit->varyHashOn('method', 'GET'); + $this->assertSame('aa33ceaf98a63b8bd52b3986a9ee06cb', $rateLimit->getHash()); + + $rateLimit->varyHashOn('url', 'http://url'); + $this->assertSame('8288acbaf1221b58a2440c01448f5021', $rateLimit->getHash()); + } +} diff --git a/tests/Model/StoredRateLimitTest.php b/tests/Model/StoredRateLimitTest.php new file mode 100644 index 0000000..4bc4d0a --- /dev/null +++ b/tests/Model/StoredRateLimitTest.php @@ -0,0 +1,48 @@ +assertFalse($storedRateLimit->isOutdated()); + + $storedRateLimit = new StoredRateLimit(new RateLimit(10, 10), 1, new \DateTimeImmutable('-1 day')); + $this->assertTrue($storedRateLimit->isOutdated()); + } + + public function testWithHitsCreateANewObject(): void + { + $storedRateLimit = new StoredRateLimit(new RateLimit(10, 10), 1, new \DateTimeImmutable('+1 day')); + $newStoredRateLimit = $storedRateLimit->withHits(5); + + $this->assertNotSame($storedRateLimit, $newStoredRateLimit); + $this->assertSame(5, $newStoredRateLimit->getHits()); + } + + public function testItComputesLimitReachedMessage(): void + { + $storedRateLimit = new StoredRateLimit($rateLimit = new RateLimit(1000, 60), 1, new \DateTimeImmutable('2020-06-01')); + $rateLimit->varyHashOn('http_method', 'GET'); + $rateLimit->varyHashOn('customer', 'customer-test'); + + $this->assertSame( + [ + 'message' => 'Too many requests. Only 1000 calls allowed every 60 seconds.', + 'limit' => 1000, + 'period' => 60, + 'until' => '2020-06-01 00:00:00', + 'vary' => '{"http_method":"GET","customer":"customer-test"}', + ], + $storedRateLimit->getLimitReachedOutput() + ); + } +} diff --git a/tests/RateLimitModifier/HttpMethodRateLimitModifierTest.php b/tests/RateLimitModifier/HttpMethodRateLimitModifierTest.php new file mode 100644 index 0000000..72b8b89 --- /dev/null +++ b/tests/RateLimitModifier/HttpMethodRateLimitModifierTest.php @@ -0,0 +1,50 @@ +assertTrue($rateLimitModifier->support(new Request())); + } + + /** + * @dataProvider itAddVaryOnHttpMethodProvider + */ + public function testItAddVaryOnHttpMethod(string $method): void + { + $rateLimitModifier = new HttpMethodRateLimitModifier(); + + $request = new Request([], [], [], [], [], ['REQUEST_METHOD' => $method]); + $rateLimitModifier->modifyRateLimit($request, $rateLimit = new RateLimit(10, 10)); + + $this->assertSame("{\"http_method\":\"$method\"}", $rateLimit->getDiscriminator()); + } + + /** + * @return array> + */ + public function itAddVaryOnHttpMethodProvider(): array + { + return [ + [ + 'method' => 'GET', + ], + [ + 'method' => 'POST', + ], + [ + 'method' => 'PATCH', + ], + ]; + } +} diff --git a/tests/RateLimitModifier/RequestAttributeRateLimitModifierTest.php b/tests/RateLimitModifier/RequestAttributeRateLimitModifierTest.php new file mode 100644 index 0000000..faf68cf --- /dev/null +++ b/tests/RateLimitModifier/RequestAttributeRateLimitModifierTest.php @@ -0,0 +1,28 @@ +assertFalse($rateLimitModifier->support(new Request())); + $this->assertTrue($rateLimitModifier->support(new Request([], [], ['uid' => 'uid-test']))); + } + + public function testItAddVaryOnUid(): void + { + $rateLimitModifier = new RequestAttributeRateLimitModifier('uid'); + $request = new Request([], [], ['uid' => 'uid-test']); + $rateLimitModifier->modifyRateLimit($request, $rateLimit = new RateLimit(10, 10)); + $this->assertSame('{"uid":"uid-test"}', $rateLimit->getDiscriminator()); + } +}