diff --git a/Annotation/RateLimit.php b/Annotation/RateLimit.php index 5f252c6..9497817 100644 --- a/Annotation/RateLimit.php +++ b/Annotation/RateLimit.php @@ -26,6 +26,11 @@ class RateLimit extends ConfigurationAnnotation */ protected $period = 3600; + /** + * @var int Amount of seconds when the calls aren't available + */ + protected $blockPeriod = 0; + /** * Returns the alias name for an annotated configuration. * @@ -93,4 +98,20 @@ public function setPeriod($period) { $this->period = $period; } + + /** + * @return int + */ + public function getBlockPeriod() + { + return $this->blockPeriod; + } + + /** + * @param int $blockPeriod + */ + public function setBlockPeriod($blockPeriod) + { + $this->blockPeriod = $blockPeriod; + } } diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 726539a..aaa9033 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -129,6 +129,10 @@ public function getConfigTreeBuilder() ->isRequired() ->min(0) ->end() + ->integerNode('block_period') + ->defaultValue(0) + ->min(0) + ->end() ->end() ->end() ->end() diff --git a/EventListener/RateLimitAnnotationListener.php b/EventListener/RateLimitAnnotationListener.php index d7d88d1..739d807 100644 --- a/EventListener/RateLimitAnnotationListener.php +++ b/EventListener/RateLimitAnnotationListener.php @@ -3,7 +3,9 @@ namespace Noxlogic\RateLimitBundle\EventListener; use Noxlogic\RateLimitBundle\Annotation\RateLimit; +use Noxlogic\RateLimitBundle\Events\BlockEvent; use Noxlogic\RateLimitBundle\Events\GenerateKeyEvent; +use Noxlogic\RateLimitBundle\Events\GetResponseEvent; use Noxlogic\RateLimitBundle\Events\RateLimitEvents; use Noxlogic\RateLimitBundle\Service\RateLimitService; use Noxlogic\RateLimitBundle\Util\PathLimitProcessor; @@ -40,6 +42,7 @@ public function __construct( RateLimitService $rateLimitService, PathLimitProcessor $pathLimitProcessor ) { + //todo:use an event dispatcher passed into onKernelController $this->eventDispatcher = $eventDispatcher; $this->rateLimitService = $rateLimitService; $this->pathLimitProcessor = $pathLimitProcessor; @@ -89,7 +92,7 @@ public function onKernelController(FilterControllerEvent $event) $request->attributes->set('rate_limit_info', $rateLimitInfo); // Reset the rate limits - if(time() >= $rateLimitInfo->getResetTimestamp()) { + if(!$rateLimitInfo->isBlocked() && time() >= $rateLimitInfo->getResetTimestamp()) { $this->rateLimitService->resetRate($key); $rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod()); if (! $rateLimitInfo) { @@ -100,19 +103,35 @@ public function onKernelController(FilterControllerEvent $event) } // When we exceeded our limit, return a custom error response - if ($rateLimitInfo->getCalls() > $rateLimitInfo->getLimit()) { + if (!$rateLimitInfo->isBlocked() && $rateLimitInfo->getCalls() > $rateLimitInfo->getLimit()) { + $this->rateLimitService->setBlock( + $rateLimitInfo, + $rateLimit->getBlockPeriod() > 0 ? $rateLimit->getBlockPeriod() : $rateLimit->getPeriod() + ); + $this->eventDispatcher->dispatch(RateLimitEvents::BLOCK_AFTER, new BlockEvent($rateLimitInfo, $request)); + } + if ($rateLimitInfo->isBlocked()) { // Throw an exception if configured. if ($this->getParameter('rate_response_exception')) { $class = $this->getParameter('rate_response_exception'); throw new $class($this->getParameter('rate_response_message'), $this->getParameter('rate_response_code')); } - $message = $this->getParameter('rate_response_message'); - $code = $this->getParameter('rate_response_code'); - $event->setController(function () use ($message, $code) { + $response = new Response( + $this->getParameter('rate_response_message'), + $this->getParameter('rate_response_code') + ); + + $eventResponse = new GetResponseEvent($request, $rateLimitInfo); + $this->eventDispatcher->dispatch(RateLimitEvents::RESPONSE_SENDING_BEFORE, $eventResponse); + if ($eventResponse->hasResponse()) { + $response = $eventResponse->getResponse(); + } + + $event->setController(function () use ($response) { // @codeCoverageIgnoreStart - return new Response($message, $code); + return $response; // @codeCoverageIgnoreEnd }); $event->stopPropagation(); diff --git a/Events/BlockEvent.php b/Events/BlockEvent.php new file mode 100644 index 0000000..56e13ee --- /dev/null +++ b/Events/BlockEvent.php @@ -0,0 +1,49 @@ +rateLimitInfo = $rateLimitInfo; + $this->request = $request; + } + + + /** + * @return RateLimitInfo + */ + public function getRateLimitInfo() + { + return $this->rateLimitInfo; + } + + /** + * @return Request + */ + public function getRequest() + { + return $this->request; + } +} diff --git a/Events/GetResponseEvent.php b/Events/GetResponseEvent.php new file mode 100644 index 0000000..848a9c4 --- /dev/null +++ b/Events/GetResponseEvent.php @@ -0,0 +1,79 @@ +request = $request; + $this->rateLimitInfo = $rateLimitInfo; + } + + + /** + * @return Response + */ + public function getResponse() + { + return $this->response; + } + + /** + * @param Response $response + */ + public function setResponse(Response $response) + { + $this->response = $response; + } + + /** + * @return bool + */ + public function hasResponse() + { + return null !== $this->response; + } + + /** + * @return Request + */ + public function getRequest() + { + return $this->request; + } + + /** + * @return RateLimitInfo + */ + public function getRateLimitInfo() + { + return $this->rateLimitInfo; + } +} diff --git a/Events/RateLimitEvents.php b/Events/RateLimitEvents.php index f3ea169..8a55c53 100644 --- a/Events/RateLimitEvents.php +++ b/Events/RateLimitEvents.php @@ -4,5 +4,18 @@ final class RateLimitEvents { - const GENERATE_KEY = 'ratelimit.generate.key'; + /** + * This event is dispatched when generating a key is doing + */ + const GENERATE_KEY = 'ratelimit.generate.key'; + + /** + * This event is dispatched after a block happened + */ + const BLOCK_AFTER = 'ratelimit.block.after'; + + /** + * This event is dispatched before response is sent + */ + const RESPONSE_SENDING_BEFORE = 'ratelimit.response.sending.before'; } diff --git a/README.md b/README.md index d94bb8f..83ae680 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This bundle is partially inspired by a GitHub gist from Ruud Kamphuis: https://g * Simple usage through annotations * Customize rates per controller, action and even per HTTP method + * Customize the period of a lock * Multiple storage backends: Redis, Memcached and Doctrine cache ## Installation @@ -29,7 +30,7 @@ Installation takes just few easy steps: If you're not yet familiar with Composer see http://getcomposer.org. Add the NoxlogicRateLimitBundle in your composer.json: -```js +```json { "require": { "noxlogic/ratelimit-bundle": "1.x" @@ -157,8 +158,10 @@ noxlogic_rate_limit: - * limit: ~ # Required period: ~ # Required + block_period: ~ # Optional # - { path: /api, limit: 1000, period: 3600 } + # - { path: /auth, limit: 1000, period: 3600, block_period: 7200 } # - { path: /dashboard, limit: 100, period: 3600, methods: ['GET', 'POST']} # Should the FOS OAuthServerBundle listener be enabled @@ -188,6 +191,27 @@ public function someApiAction() } ``` +### Simple rate limit with custom block period + +To enable this feature, you should write `blockPeriod` into the annotation `RateLimit`. If `blockPeriod` is not set up +its value will be equaled `period` parameter. + +```php +getRequest(); + $rateLimitInfo = $event->getRateLimitInfo(); + + $event->setResponse(new JsonResponse( + array( + "error" => sprintf("Access for URL %s deny", $request->getRequestUri()), + "block_period" => $rateLimitInfo->getResetTimestamp() - time() + ), + Response::HTTP_TOO_MANY_REQUESTS + )); + } +} +``` + +## Logging a block into a journal + +You could do a lot of things with an event `ratelimit.block.after`. This event is dispatched just before a lock is set. +For example below you could register every blocked URL in your security journal. + +```yaml +services: + mybundle.listener.rate_limit_log_block: + class: MyBundle\Listener\RateLimitLogBlockListener + arguments: ["@my.service.journal"] + tags: + - { name: kernel.event_listener, event: 'ratelimit.block.after', method: 'onLog' } +``` + +```php +journal = $journal; + } + + public function onLog(BlockEvent $event) + { + $message = sprintf( + "Somebody tries to make brute force on URL %s with login %s. They will be lock until %s", + $event->getRequest()->getUri(), + $event->getRequest()->request->get('login'), + date(\DateTime::ISO8601, $event->getRateLimitInfo()->getResetTimestamp()) + ); + $this->journal->log($message); + } +} +``` ## Throwing exceptions Instead of returning a Response object when a rate limit has exceeded, it's also possible to throw an exception. This allows you to easily handle the rate limit on another level, for instance by capturing the ``kernel.exception`` event. - ## Running tests If you want to run the tests use: diff --git a/Service/RateLimitInfo.php b/Service/RateLimitInfo.php index 49eb5b0..f165d65 100644 --- a/Service/RateLimitInfo.php +++ b/Service/RateLimitInfo.php @@ -8,6 +8,16 @@ class RateLimitInfo protected $calls; protected $resetTimestamp; + /** + * @var bool + */ + protected $blocked = false; + + /** + * @var string the key of rate limit + */ + protected $key; + /** * @return mixed */ @@ -55,4 +65,44 @@ public function setResetTimestamp($resetTimestamp) { $this->resetTimestamp = $resetTimestamp; } + + /** + * Return true if the action is blocked + * + * @return bool + */ + public function isBlocked() + { + return $this->blocked; + } + + /** + * Set block the action + * + * @param bool $blocked + */ + public function setBlocked($blocked) + { + $this->blocked = (bool)$blocked; + } + + /** + * Return the key of rate limit + * + * @return string + */ + public function getKey() + { + return $this->key; + } + + /** + * Set the key into rate limit + * + * @param string $key + */ + public function setKey($key) + { + $this->key = (string)$key; + } } diff --git a/Service/RateLimitService.php b/Service/RateLimitService.php index 1dcf891..b863340 100644 --- a/Service/RateLimitService.php +++ b/Service/RateLimitService.php @@ -54,4 +54,17 @@ public function resetRate($key) { return $this->storage->resetRate($key); } + + /** + * Set block for the call + * + * @param RateLimitInfo $rateLimitInfo + * @param integer $blockPeriod + * + * @return bool + */ + public function setBlock(RateLimitInfo $rateLimitInfo, $blockPeriod) + { + return $this->storage->setBlock($rateLimitInfo, $blockPeriod); + } } diff --git a/Service/Storage/DoctrineCache.php b/Service/Storage/DoctrineCache.php index 3adf5f3..da869cf 100644 --- a/Service/Storage/DoctrineCache.php +++ b/Service/Storage/DoctrineCache.php @@ -24,7 +24,7 @@ public function getRateInfo($key) return false; } - return $this->createRateInfo($info); + return $this->createRateInfo($key, $info); } public function limitRate($key) @@ -40,19 +40,20 @@ public function limitRate($key) $this->client->save($key, $info, $expire); - return $this->createRateInfo($info); + return $this->createRateInfo($key, $info); } public function createRate($key, $limit, $period) { - $info = array(); - $info['limit'] = $limit; - $info['calls'] = 1; - $info['reset'] = time() + $period; + $info = array(); + $info['limit'] = $limit; + $info['calls'] = 1; + $info['reset'] = time() + $period; + $info['blocked'] = 0; $this->client->save($key, $info, $period); - return $this->createRateInfo($info); + return $this->createRateInfo($key, $info); } public function resetRate($key) @@ -62,12 +63,37 @@ public function resetRate($key) return true; } - private function createRateInfo(array $info) + /** + * @inheritDoc + */ + public function setBlock(RateLimitInfo $rateLimitInfo, $periodBlock) + { + $resetTimestamp = time() + $periodBlock; + $this->client->save( + $rateLimitInfo->getKey(), + array( + 'limit' => $rateLimitInfo->getLimit(), + 'calls' => $rateLimitInfo->getCalls(), + 'reset' => $resetTimestamp, + 'blocked' => 1, + ), + $periodBlock + ); + + $rateLimitInfo->setBlocked(true); + $rateLimitInfo->setResetTimestamp($resetTimestamp); + + return true; + } + + private function createRateInfo($key, array $info) { $rateLimitInfo = new RateLimitInfo(); $rateLimitInfo->setLimit($info['limit']); $rateLimitInfo->setCalls($info['calls']); $rateLimitInfo->setResetTimestamp($info['reset']); + $rateLimitInfo->setBlocked(isset($info['blocked']) && $info['blocked']); + $rateLimitInfo->setKey($key); return $rateLimitInfo; } diff --git a/Service/Storage/Memcache.php b/Service/Storage/Memcache.php index 67253a9..5ce674c 100644 --- a/Service/Storage/Memcache.php +++ b/Service/Storage/Memcache.php @@ -20,7 +20,7 @@ public function getRateInfo($key) { $info = $this->client->get($key); - return $this->createRateInfo($info); + return $this->createRateInfo($key, $info); } public function limitRate($key) @@ -37,7 +37,7 @@ public function limitRate($key) $this->client->cas($cas, $key, $info); } while ($this->client->getResultCode() == \Memcached::RES_DATA_EXISTS && $i++ < 5); - return $this->createRateInfo($info); + return $this->createRateInfo($key, $info); } public function createRate($key, $limit, $period) @@ -46,24 +46,52 @@ public function createRate($key, $limit, $period) $info['limit'] = $limit; $info['calls'] = 1; $info['reset'] = time() + $period; + $info['blocked'] = 0; $this->client->set($key, $info, $period); - return $this->createRateInfo($info); + return $this->createRateInfo($key, $info); } public function resetRate($key) { $this->client->delete($key); + + return true; + } + + /** + * @inheritDoc + */ + public function setBlock(RateLimitInfo $rateLimitInfo, $periodBlock) + { + $resetTimestamp = time() + $periodBlock; + + $this->client->set( + $rateLimitInfo->getKey(), + [ + 'limit' => $rateLimitInfo->getLimit(), + 'calls' => $rateLimitInfo->getCalls(), + 'reset' => $resetTimestamp, + 'blocked' => 1, + ], + $periodBlock + ); + + $rateLimitInfo->setBlocked(true); + $rateLimitInfo->setResetTimestamp($resetTimestamp); + return true; } - private function createRateInfo(array $info) + private function createRateInfo($key, array $info) { $rateLimitInfo = new RateLimitInfo(); $rateLimitInfo->setLimit($info['limit']); $rateLimitInfo->setCalls($info['calls']); $rateLimitInfo->setResetTimestamp($info['reset']); + $rateLimitInfo->setBlocked(isset($info['blocked']) && $info['blocked']); + $rateLimitInfo->setKey($key); return $rateLimitInfo; } diff --git a/Service/Storage/PhpRedis.php b/Service/Storage/PhpRedis.php index 37d135f..0af8495 100644 --- a/Service/Storage/PhpRedis.php +++ b/Service/Storage/PhpRedis.php @@ -29,6 +29,8 @@ public function getRateInfo($key) $rateLimitInfo->setLimit($info['limit']); $rateLimitInfo->setCalls($info['calls']); $rateLimitInfo->setResetTimestamp($info['reset']); + $rateLimitInfo->setBlocked(isset($info['blocked']) && $info['blocked']); + $rateLimitInfo->setKey($key); return $rateLimitInfo; } @@ -52,6 +54,7 @@ public function createRate($key, $limit, $period) $this->client->hset($key, 'limit', $limit); $this->client->hset($key, 'calls', 1); $this->client->hset($key, 'reset', $reset); + $this->client->hset($key, 'blocked', 0); $this->client->expire($key, $period); $rateLimitInfo = new RateLimitInfo(); @@ -69,4 +72,21 @@ public function resetRate($key) return true; } + /** + * @inheritDoc + */ + public function setBlock(RateLimitInfo $rateLimitInfo, $periodBlock) + { + $resetTimestamp = time() + $periodBlock; + $this->client->hset($rateLimitInfo->getKey(), 'limit', $rateLimitInfo->getLimit()); + $this->client->hset($rateLimitInfo->getKey(), 'calls', $rateLimitInfo->getCalls()); + $this->client->hset($rateLimitInfo->getKey(), 'reset', $resetTimestamp); + $this->client->hset($rateLimitInfo->getKey(), 'blocked', 1); + $this->client->expire($rateLimitInfo->getKey(), $periodBlock); + + $rateLimitInfo->setBlocked(true); + $rateLimitInfo->setResetTimestamp($resetTimestamp); + + return true; + } } diff --git a/Service/Storage/PsrCache.php b/Service/Storage/PsrCache.php index 0d2b8a7..9c83d04 100644 --- a/Service/Storage/PsrCache.php +++ b/Service/Storage/PsrCache.php @@ -25,7 +25,7 @@ public function getRateInfo($key) return false; } - return $this->createRateInfo($item->get()); + return $this->createRateInfo($key, $item->get()); } public function limitRate($key) @@ -43,15 +43,16 @@ public function limitRate($key) $this->client->save($item); - return $this->createRateInfo($info); + return $this->createRateInfo($key, $info); } public function createRate($key, $limit, $period) { $info = [ - 'limit' => $limit, - 'calls' => 1, - 'reset' => time() + $period, + 'limit' => $limit, + 'calls' => 1, + 'reset' => time() + $period, + 'blocked' => 0 ]; $item = $this->client->getItem($key); $item->set($info); @@ -59,7 +60,7 @@ public function createRate($key, $limit, $period) $this->client->save($item); - return $this->createRateInfo($info); + return $this->createRateInfo($key, $info); } public function resetRate($key) @@ -69,13 +70,43 @@ public function resetRate($key) return true; } - private function createRateInfo(array $info) + private function createRateInfo($key, array $info) { $rateLimitInfo = new RateLimitInfo(); $rateLimitInfo->setLimit($info['limit']); $rateLimitInfo->setCalls($info['calls']); $rateLimitInfo->setResetTimestamp($info['reset']); + $rateLimitInfo->setBlocked(isset($info['blocked']) && $info['blocked']); + $rateLimitInfo->setKey($key); return $rateLimitInfo; } + + /** + * @inheritDoc + */ + public function setBlock(RateLimitInfo $rateLimitInfo, $periodBlock) + { + $resetTimestamp = time() + $periodBlock; + $info = [ + 'limit' => $rateLimitInfo->getLimit(), + 'calls' => 1, + 'reset' => $resetTimestamp, + 'blocked' => 1 + ]; + + $item = $this->client->getItem($rateLimitInfo->getKey()); + $item->set($info); + $item->expiresAfter($periodBlock); + + if (!$this->client->save($item)) { + return false; + } + + $rateLimitInfo->setBlocked(1); + $rateLimitInfo->setResetTimestamp($resetTimestamp); + + return true; + } + } diff --git a/Service/Storage/Redis.php b/Service/Storage/Redis.php index 270cb76..7549e14 100644 --- a/Service/Storage/Redis.php +++ b/Service/Storage/Redis.php @@ -28,6 +28,8 @@ public function getRateInfo($key) $rateLimitInfo->setLimit($info['limit']); $rateLimitInfo->setCalls($info['calls']); $rateLimitInfo->setResetTimestamp($info['reset']); + $rateLimitInfo->setBlocked(isset($info['blocked']) && $info['blocked']); + $rateLimitInfo->setKey($key); return $rateLimitInfo; } @@ -51,12 +53,14 @@ public function createRate($key, $limit, $period) $this->client->hset($key, 'limit', $limit); $this->client->hset($key, 'calls', 1); $this->client->hset($key, 'reset', $reset); + $this->client->hset($key, 'blocked', 0); $this->client->expire($key, $period); $rateLimitInfo = new RateLimitInfo(); $rateLimitInfo->setLimit($limit); $rateLimitInfo->setCalls(1); $rateLimitInfo->setResetTimestamp($reset); + $rateLimitInfo->setBlocked(false); return $rateLimitInfo; } @@ -68,4 +72,21 @@ public function resetRate($key) return true; } + /** + * @inheritDoc + */ + public function setBlock(RateLimitInfo $rateLimitInfo, $periodBlock) + { + $resetTimestamp = time() + $periodBlock; + $this->client->hset($rateLimitInfo->getKey(), 'limit', $rateLimitInfo->getLimit()); + $this->client->hset($rateLimitInfo->getKey(), 'calls', $rateLimitInfo->getCalls()); + $this->client->hset($rateLimitInfo->getKey(), 'reset', $resetTimestamp); + $this->client->hset($rateLimitInfo->getKey(), 'blocked', 1); + $this->client->expire($rateLimitInfo->getKey(), $periodBlock); + + $rateLimitInfo->setBlocked(true); + $rateLimitInfo->setResetTimestamp($resetTimestamp); + + return true; + } } diff --git a/Service/Storage/SimpleCache.php b/Service/Storage/SimpleCache.php index 7821633..37d4d16 100644 --- a/Service/Storage/SimpleCache.php +++ b/Service/Storage/SimpleCache.php @@ -24,7 +24,7 @@ public function getRateInfo($key) return false; } - return $this->createRateInfo($info); + return $this->createRateInfo($key, $info); } public function limitRate($key) @@ -39,19 +39,20 @@ public function limitRate($key) $this->client->set($key, $info, $ttl); - return $this->createRateInfo($info); + return $this->createRateInfo($key, $info); } public function createRate($key, $limit, $period) { $info = [ - 'limit' => $limit, - 'calls' => 1, - 'reset' => time() + $period, + 'limit' => $limit, + 'calls' => 1, + 'reset' => time() + $period, + 'blocked' => 0 ]; $this->client->set($key, $info, $period); - return $this->createRateInfo($info); + return $this->createRateInfo($key, $info); } public function resetRate($key) @@ -61,13 +62,37 @@ public function resetRate($key) return true; } - private function createRateInfo(array $info) + private function createRateInfo($key, array $info) { $rateLimitInfo = new RateLimitInfo(); $rateLimitInfo->setLimit($info['limit']); $rateLimitInfo->setCalls($info['calls']); $rateLimitInfo->setResetTimestamp($info['reset']); + $rateLimitInfo->setBlocked(isset($info['blocked']) && $info['blocked']); + $rateLimitInfo->setKey($key); return $rateLimitInfo; } + + /** + * @inheritDoc + */ + public function setBlock(RateLimitInfo $rateLimitInfo, $periodBlock) + { + $resetTimestamp = time() + $periodBlock; + $info = [ + 'limit' => $rateLimitInfo->getLimit(), + 'calls' => $rateLimitInfo->getCalls(), + 'reset' => $resetTimestamp, + 'blocked' => 1 + ]; + if (!$this->client->set($rateLimitInfo->getKey(), $info, $periodBlock)) { + return false; + } + + $rateLimitInfo->setBlocked(true); + $rateLimitInfo->setResetTimestamp($resetTimestamp); + + return true; + } } diff --git a/Service/Storage/StorageInterface.php b/Service/Storage/StorageInterface.php index 5691c3e..a49e192 100644 --- a/Service/Storage/StorageInterface.php +++ b/Service/Storage/StorageInterface.php @@ -37,4 +37,14 @@ public function createRate($key, $limit, $period); * @param $key */ public function resetRate($key); + + /** + * Set block for the call + * + * @param RateLimitInfo $rateLimitInfo + * @param integer $periodBlock + * + * @return bool + */ + public function setBlock(RateLimitInfo $rateLimitInfo, $periodBlock); } diff --git a/Tests/Annotation/RateLimitTest.php b/Tests/Annotation/RateLimitTest.php index 33dfeba..3e822c1 100644 --- a/Tests/Annotation/RateLimitTest.php +++ b/Tests/Annotation/RateLimitTest.php @@ -18,19 +18,22 @@ public function testConstruction() $this->assertEquals(-1, $annot->getLimit()); $this->assertEmpty($annot->getMethods()); $this->assertEquals(3600, $annot->getPeriod()); + $this->assertEquals(0, $annot->getBlockPeriod()); } public function testConstructionWithValues() { - $annot = new RateLimit(array('limit' => 1234, 'period' => 1000)); + $annot = new RateLimit(array('limit' => 1234, 'period' => 1000, 'blockPeriod' => 7200)); $this->assertEquals(1234, $annot->getLimit()); $this->assertEquals(1000, $annot->getPeriod()); + $this->assertEquals(7200, $annot->getBlockPeriod()); - $annot = new RateLimit(array('methods' => 'POST', 'limit' => 1234, 'period' => 1000)); + $annot = new RateLimit(array('methods' => 'POST', 'limit' => 1234, 'period' => 1000, 'blockPeriod' => 7200)); $this->assertEquals(1234, $annot->getLimit()); $this->assertEquals(1000, $annot->getPeriod()); $this->assertEquals(['POST'], $annot->getMethods()); + $this->assertEquals(7200, $annot->getBlockPeriod()); } public function testConstructionWithMethods() diff --git a/Tests/DependencyInjection/ConfigurationTest.php b/Tests/DependencyInjection/ConfigurationTest.php index cceea5f..1911c85 100644 --- a/Tests/DependencyInjection/ConfigurationTest.php +++ b/Tests/DependencyInjection/ConfigurationTest.php @@ -73,7 +73,8 @@ public function testPathLimitConfiguration() 'path' => 'api/', 'methods' => array('GET'), 'limit' => 100, - 'period' => 60 + 'period' => 60, + 'block_period' => 120 ) ); @@ -92,13 +93,15 @@ public function testMultiplePathLimitConfiguration() 'path' => 'api/', 'methods' => array('GET', 'POST'), 'limit' => 200, - 'period' => 10 + 'period' => 10, + 'block_period' => 20 ), 'api2' => array( 'path' => 'api2/', 'methods' => array('*'), 'limit' => 1000, - 'period' => 15 + 'period' => 15, + 'block_period' => 0 ) ); @@ -117,12 +120,14 @@ public function testDefaultPathLimitMethods() 'path' => 'api/', 'methods' => array('GET', 'POST'), 'limit' => 200, - 'period' => 10 + 'period' => 10, + 'block_period' => 0 ), 'api2' => array( 'path' => 'api2/', 'limit' => 1000, - 'period' => 15 + 'period' => 15, + 'block_period' => 0, ) ); diff --git a/Tests/DependencyInjection/NoxlogicRateLimitExtensionTest.php b/Tests/DependencyInjection/NoxlogicRateLimitExtensionTest.php index 959dfa0..02b54b3 100644 --- a/Tests/DependencyInjection/NoxlogicRateLimitExtensionTest.php +++ b/Tests/DependencyInjection/NoxlogicRateLimitExtensionTest.php @@ -42,7 +42,8 @@ public function testPathLimitsParameter() 'path' => 'api/', 'methods' => array('GET'), 'limit' => 100, - 'period' => 60 + 'period' => 60, + 'block_period' => 0 ) ); diff --git a/Tests/EventListener/MockStorage.php b/Tests/EventListener/MockStorage.php index 56d8a92..64d57a2 100644 --- a/Tests/EventListener/MockStorage.php +++ b/Tests/EventListener/MockStorage.php @@ -23,6 +23,8 @@ public function getRateInfo($key) $rateLimitInfo->setCalls($info['calls']); $rateLimitInfo->setResetTimestamp($info['reset']); $rateLimitInfo->setLimit($info['limit']); + $rateLimitInfo->setBlocked(isset($info['blocked']) && $info['blocked']); + return $rateLimitInfo; } @@ -71,4 +73,21 @@ public function createMockRate($key, $limit, $period, $calls) $this->rates[$key] = array('calls' => $calls, 'limit' => $limit, 'reset' => (time() + $period)); return $this->getRateInfo($key); } + + /** + * @inheritDoc + */ + public function setBlock(RateLimitInfo $rateLimitInfo, $periodBlock) + { + $resetTimestamp = time() + $periodBlock; + $this->rates[$rateLimitInfo->getKey()] = array( + 'calls' => $rateLimitInfo->getCalls(), + 'limit' => $rateLimitInfo->getLimit(), + 'reset' => $resetTimestamp, + 'blocked' => 1 + ); + + $rateLimitInfo->setBlocked(1); + $rateLimitInfo->setResetTimestamp($resetTimestamp); + } } diff --git a/Tests/EventListener/RateLimitAnnotationListenerTest.php b/Tests/EventListener/RateLimitAnnotationListenerTest.php index 676fae2..a5e6d3d 100644 --- a/Tests/EventListener/RateLimitAnnotationListenerTest.php +++ b/Tests/EventListener/RateLimitAnnotationListenerTest.php @@ -154,6 +154,9 @@ public function testRateLimit() new RateLimit(array('limit' => 5, 'period' => 5)), )); + $listener->setParameter('rate_response_code', 200); + $listener->setParameter('rate_response_message', 'Test message'); + $listener->onKernelController($event); $this->assertInternalType('array', $event->getController()); $listener->onKernelController($event); @@ -181,6 +184,9 @@ public function testRateLimitThrottling() new RateLimit(array('limit' => 5, 'period' => 3)), )); + $listener->setParameter('rate_response_code', 200); + $listener->setParameter('rate_response_message', 'Test message'); + // Throttled $storage = $this->getMockStorage(); $storage->createMockRate('Noxlogic.RateLimitBundle.EventListener.Tests.MockController.mockAction', 5, 10, 6); @@ -323,7 +329,8 @@ protected function createListener($expects) $mockDispatcher = $this->getMockBuilder('Symfony\\Component\\EventDispatcher\\EventDispatcherInterface')->getMock(); $mockDispatcher ->expects($expects) - ->method('dispatch'); + ->method('dispatch') + ; $rateLimitService = new RateLimitService(); $rateLimitService->setStorage($this->getMockStorage()); diff --git a/Tests/Events/BlockEventTest.php b/Tests/Events/BlockEventTest.php new file mode 100644 index 0000000..725600a --- /dev/null +++ b/Tests/Events/BlockEventTest.php @@ -0,0 +1,19 @@ +getMockBuilder('Noxlogic\RateLimitBundle\Service\RateLimitInfo')->getMock(); + $request = $this->getMockBuilder('Symfony\Component\HttpFoundation\Request')->getMock(); + $event = new BlockEvent($rateLimitInfo, $request); + + self::assertSame($rateLimitInfo, $event->getRateLimitInfo()); + self::assertSame($request, $event->getRequest()); + } +} diff --git a/Tests/Events/GetResponseEventTest.php b/Tests/Events/GetResponseEventTest.php new file mode 100644 index 0000000..1108da7 --- /dev/null +++ b/Tests/Events/GetResponseEventTest.php @@ -0,0 +1,60 @@ +getMockBuilder('Symfony\Component\HttpFoundation\Request')->getMock(); + $rateLimitInfo = $this->getMockBuilder('Noxlogic\RateLimitBundle\Service\RateLimitInfo')->getMock(); + + $event = new GetResponseEvent($request, $rateLimitInfo); + + self::assertSame($request, $event->getRequest()); + self::assertSame($rateLimitInfo, $event->getRateLimitInfo()); + + return $event; + } + + /** + * @depends testConstruct + * + * @param GetResponseEvent $event + */ + public function testHasResponseReturnFalse(GetResponseEvent $event) + { + self::assertFalse($event->hasResponse()); + } + + /** + * @depends testConstruct + * + * @param GetResponseEvent $event + * @return GetResponseEvent + */ + public function testSetResponse(GetResponseEvent $event) + { + $response = $this->getMockBuilder('Symfony\Component\HttpFoundation\Response')->getMock(); + $event->setResponse($response); + self::assertSame($response, $event->getResponse()); + + return $event; + } + + /** + * @depends testSetResponse + * + * @param GetResponseEvent $event + */ + public function testHasResponseReturnTrue(GetResponseEvent $event) + { + self::assertTrue($event->hasResponse()); + } +} diff --git a/Tests/Events/RateLimitEventsTest.php b/Tests/Events/RateLimitEventsTest.php index 37688fb..19e25b3 100644 --- a/Tests/Events/RateLimitEventsTest.php +++ b/Tests/Events/RateLimitEventsTest.php @@ -2,15 +2,15 @@ namespace Noxlogic\RateLimitBundle\Tests\Annotation; -use Noxlogic\RateLimitBundle\Events\GenerateKeyEvent; use Noxlogic\RateLimitBundle\Events\RateLimitEvents; use Noxlogic\RateLimitBundle\Tests\TestCase; -use Symfony\Component\HttpFoundation\Request; class RateLimitEventsTest extends TestCase { public function testConstants() { $this->assertEquals('ratelimit.generate.key', RateLimitEvents::GENERATE_KEY); + $this->assertEquals('ratelimit.block.after', RateLimitEvents::BLOCK_AFTER); + $this->assertEquals('ratelimit.response.sending.before', RateLimitEvents::RESPONSE_SENDING_BEFORE); } } diff --git a/Tests/Service/RateLimitInfoTest.php b/Tests/Service/RateLimitInfoTest.php index 7412913..35659c8 100644 --- a/Tests/Service/RateLimitInfoTest.php +++ b/Tests/Service/RateLimitInfoTest.php @@ -23,6 +23,14 @@ public function testRateInfoSetters() $rateInfo->setResetTimestamp(100000); $this->assertEquals(100000, $rateInfo->getResetTimestamp()); + + $this->assertFalse($rateInfo->isBlocked()); + + $rateInfo->setBlocked(true); + $this->assertTrue($rateInfo->isBlocked()); + + $rateInfo->setKey('test'); + $this->assertEquals('test', $rateInfo->getKey()); } } diff --git a/Tests/Service/RateLimitServiceTest.php b/Tests/Service/RateLimitServiceTest.php index 39c109b..f125095 100644 --- a/Tests/Service/RateLimitServiceTest.php +++ b/Tests/Service/RateLimitServiceTest.php @@ -70,4 +70,19 @@ public function testResetRate() $service->setStorage($mockStorage); $service->resetRate('testkey'); } + + public function testSetBlock() + { + $mockStorage = $this->getMockBuilder('Noxlogic\\RateLimitBundle\\Service\\Storage\\StorageInterface')->getMock(); + $rateLimitInfo = $this->getMockBuilder('Noxlogic\RateLimitBundle\Service\RateLimitInfo')->getMock(); + + $mockStorage + ->expects(self::once()) + ->method('setBlock') + ->with($rateLimitInfo, 100); + + $service = new RateLimitService(); + $service->setStorage($mockStorage); + $service->setBlock($rateLimitInfo, 100); + } } diff --git a/Tests/Service/Storage/DoctrineCacheTest.php b/Tests/Service/Storage/DoctrineCacheTest.php index d326c91..5fd3fbd 100644 --- a/Tests/Service/Storage/DoctrineCacheTest.php +++ b/Tests/Service/Storage/DoctrineCacheTest.php @@ -3,6 +3,7 @@ namespace Noxlogic\RateLimitBundle\Tests\Service\Storage; +use Noxlogic\RateLimitBundle\Service\RateLimitInfo; use Noxlogic\RateLimitBundle\Service\Storage\DoctrineCache; use Noxlogic\RateLimitBundle\Tests\TestCase; @@ -15,7 +16,7 @@ public function testGetRateInfo() $client->expects($this->once()) ->method('fetch') ->with('foo') - ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234))); + ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234, 'blocked' => 1))); $storage = new DoctrineCache($client); $rli = $storage->getRateInfo('foo'); @@ -23,6 +24,7 @@ public function testGetRateInfo() $this->assertEquals(100, $rli->getLimit()); $this->assertEquals(50, $rli->getCalls()); $this->assertEquals(1234, $rli->getResetTimestamp()); + $this->assertTrue($rli->isBlocked()); } public function testCreateRate() @@ -83,4 +85,26 @@ public function testResetRate() $storage = new DoctrineCache($client); $this->assertTrue($storage->resetRate('foo')); } + + public function testSetBlock() + { + $client = $this->getMockBuilder('Doctrine\\Common\\Cache\\ArrayCache') + ->setMethods(array('save')) + ->getMock(); + $client->expects(self::once()) + ->method('save') + ->with('foo', ['limit' => 5, 'calls' => 6, 'reset' => time() + 100, 'blocked' => 1,], 100) + ->willReturn(true); + + $rateLimitInfo = new RateLimitInfo(); + $rateLimitInfo->setKey('foo'); + $rateLimitInfo->setResetTimestamp(10); + $rateLimitInfo->setLimit(5); + $rateLimitInfo->setCalls(6); + + $storage = new DoctrineCache($client); + self::assertTrue($storage->setBlock($rateLimitInfo, 100)); + self::assertTrue($rateLimitInfo->isBlocked()); + self::assertGreaterThan(100, $rateLimitInfo->getResetTimestamp()); + } } diff --git a/Tests/Service/Storage/MemcacheTest.php b/Tests/Service/Storage/MemcacheTest.php index 30c88c0..392562c 100644 --- a/Tests/Service/Storage/MemcacheTest.php +++ b/Tests/Service/Storage/MemcacheTest.php @@ -2,6 +2,7 @@ namespace Noxlogic\RateLimitBundle\Tests\Service\Storage; +use Noxlogic\RateLimitBundle\Service\RateLimitInfo; use Noxlogic\RateLimitBundle\Service\Storage\Memcache; use Noxlogic\RateLimitBundle\Tests\TestCase; @@ -22,7 +23,7 @@ public function testGetRateInfo() $client->expects($this->once()) ->method('get') ->with('foo') - ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234))); + ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234, 'blocked' => 1))); $storage = new Memcache($client); $rli = $storage->getRateInfo('foo'); @@ -30,6 +31,7 @@ public function testGetRateInfo() $this->assertEquals(100, $rli->getLimit()); $this->assertEquals(50, $rli->getCalls()); $this->assertEquals(1234, $rli->getResetTimestamp()); + $this->assertTrue($rli->isBlocked()); } public function testCreateRate() @@ -96,4 +98,21 @@ public function testResetRate() $this->assertTrue($storage->resetRate('foo')); } + public function testSetBlock() + { + $client = @$this->getMockBuilder('\\Memcached') + ->setMethods(array('set')) + ->getMock(); + $client->expects(self::once()) + ->method('set') + ->with('foo', ['limit' => null, 'calls' => null, 'reset' => time() + 100, 'blocked' => 1], 100); + + $rateLimitInfo = new RateLimitInfo(); + $rateLimitInfo->setKey('foo'); + + $storage = new Memcache($client); + self::assertTrue($storage->setBlock($rateLimitInfo, 100)); + self::assertTrue($rateLimitInfo->isBlocked()); + self::assertGreaterThan(10, $rateLimitInfo->getResetTimestamp()); + } } diff --git a/Tests/Service/Storage/PhpRedisTest.php b/Tests/Service/Storage/PhpRedisTest.php index c0f6b4d..52f8e33 100644 --- a/Tests/Service/Storage/PhpRedisTest.php +++ b/Tests/Service/Storage/PhpRedisTest.php @@ -2,6 +2,7 @@ namespace Noxlogic\RateLimitBundle\Tests\Service\Storage; +use Noxlogic\RateLimitBundle\Service\RateLimitInfo; use Noxlogic\RateLimitBundle\Service\Storage\PhpRedis; use Noxlogic\RateLimitBundle\Service\Storage\Redis; use Noxlogic\RateLimitBundle\Tests\TestCase; @@ -22,7 +23,7 @@ public function testgetRateInfo() $client->expects($this->once()) ->method('hgetall') ->with('foo') - ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234))); + ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234, 'blocked' => 1))); $storage = new PhpRedis($client); $rli = $storage->getRateInfo('foo'); @@ -30,6 +31,7 @@ public function testgetRateInfo() $this->assertEquals(100, $rli->getLimit()); $this->assertEquals(50, $rli->getCalls()); $this->assertEquals(1234, $rli->getResetTimestamp()); + $this->assertTrue($rli->isBlocked()); } public function testcreateRate() @@ -40,12 +42,13 @@ public function testcreateRate() $client->expects($this->once()) ->method('expire') ->with('foo', 123); - $client->expects($this->exactly(3)) + $client->expects($this->exactly(4)) ->method('hset') ->withConsecutive( array('foo', 'limit', 100), array('foo', 'calls', 1), - array('foo', 'reset') + array('foo', 'reset'), + array('foo', 'blocked', 0) ); $storage = new PhpRedis($client); @@ -104,4 +107,32 @@ public function testresetRate() $this->assertTrue($storage->resetRate('foo')); } + public function testSetBlock() + { + $client = $this->getMockBuilder('\Redis') + ->setMethods(array('hset', 'expire')) + ->getMock(); + $client->expects(self::exactly(4)) + ->method('hset') + ->withConsecutive( + array('foo', 'limit', 2), + array('foo', 'calls', 1), + array('foo', 'reset', time() + 100), + array('foo', 'blocked', 1) + ); + $client->expects(self::once()) + ->method('expire') + ->with('foo', 100); + + $rateLimitInfo = new RateLimitInfo(); + $rateLimitInfo->setKey('foo'); + $rateLimitInfo->setResetTimestamp(10); + $rateLimitInfo->setLimit(2); + $rateLimitInfo->setCalls(1); + + $storage = new PhpRedis($client); + self::assertTrue($storage->setBlock($rateLimitInfo, 100)); + self::assertTrue($rateLimitInfo->isBlocked()); + self::assertGreaterThan(10, $rateLimitInfo->getResetTimestamp()); + } } diff --git a/Tests/Service/Storage/PsrCacheTest.php b/Tests/Service/Storage/PsrCacheTest.php index 6948ffb..bf54737 100644 --- a/Tests/Service/Storage/PsrCacheTest.php +++ b/Tests/Service/Storage/PsrCacheTest.php @@ -2,6 +2,7 @@ namespace Noxlogic\RateLimitBundle\Tests\Service\Storage; +use Noxlogic\RateLimitBundle\Service\RateLimitInfo; use Noxlogic\RateLimitBundle\Service\Storage\PsrCache; use Noxlogic\RateLimitBundle\Tests\TestCase; @@ -121,4 +122,44 @@ public function testResetRate() $storage = new PsrCache($client); $this->assertTrue($storage->resetRate('foo')); } + + public function testSetBlock() + { + $client = $this->getMockBuilder('Psr\Cache\CacheItemPoolInterface')->getMock(); + $item = $this->getMockBuilder('Psr\Cache\CacheItemInterface')->getMock(); + + $client->expects($this->once()) + ->method('getItem') + ->with('foo') + ->willReturn($item); + + $rateLimitInfo = new RateLimitInfo(); + $rateLimitInfo->setKey('foo'); + $rateLimitInfo->setCalls(1); + $rateLimitInfo->setLimit(2); + $rateLimitInfo->setResetTimestamp(time()); + + $periodBlock = 100; + $resetTimestamp = time() + $periodBlock; + $item->expects(self::once()) + ->method('set') + ->with([ + 'limit' => $rateLimitInfo->getLimit(), + 'calls' => $rateLimitInfo->getCalls(), + 'reset' => $resetTimestamp, + 'blocked' => 1 + ]); + $item->expects(self::once()) + ->method('expiresAfter') + ->with($periodBlock); + $client->expects(self::once()) + ->method('save') + ->with($item) + ->willReturn(true); + + $storage = new PsrCache($client); + self::assertTrue($storage->setBlock($rateLimitInfo, $periodBlock), 'Result of setting the block must equal true'); + self::assertTrue($rateLimitInfo->isBlocked(), 'After setting the block RateLimitInfo must contain blocked=true'); + self::assertEquals($resetTimestamp, $rateLimitInfo->getResetTimestamp()); + } } diff --git a/Tests/Service/Storage/RedisTest.php b/Tests/Service/Storage/RedisTest.php index b683b52..efcc046 100644 --- a/Tests/Service/Storage/RedisTest.php +++ b/Tests/Service/Storage/RedisTest.php @@ -2,6 +2,7 @@ namespace Noxlogic\RateLimitBundle\Tests\Service\Storage; +use Noxlogic\RateLimitBundle\Service\RateLimitInfo; use Noxlogic\RateLimitBundle\Service\Storage\Redis; use Noxlogic\RateLimitBundle\Tests\TestCase; @@ -15,7 +16,7 @@ public function testgetRateInfo() $client->expects($this->once()) ->method('hgetall') ->with('foo') - ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234))); + ->will($this->returnValue(array('limit' => 100, 'calls' => 50, 'reset' => 1234, 'blocked' => 1))); $storage = new Redis($client); $rli = $storage->getRateInfo('foo'); @@ -23,6 +24,7 @@ public function testgetRateInfo() $this->assertEquals(100, $rli->getLimit()); $this->assertEquals(50, $rli->getCalls()); $this->assertEquals(1234, $rli->getResetTimestamp()); + $this->assertTrue($rli->isBlocked()); } public function testcreateRate() @@ -33,12 +35,13 @@ public function testcreateRate() $client->expects($this->once()) ->method('expire') ->with('foo', 123); - $client->expects($this->exactly(3)) + $client->expects($this->exactly(4)) ->method('hset') ->withConsecutive( array('foo', 'limit', 100), array('foo', 'calls', 1), - array('foo', 'reset') + array('foo', 'reset'), + array('foo', 'blocked', 0) ); $storage = new Redis($client); @@ -97,4 +100,38 @@ public function testresetRate() $this->assertTrue($storage->resetRate('foo')); } + public function testSetBlock() + { + $client = $this->getMockBuilder('Predis\\Client') + ->setMethods(array('hset', 'expire')) + ->getMock(); + $client->expects(self::exactly(4)) + ->method('hset') + ->withConsecutive( + array('foo', 'limit', 2), + array('foo', 'calls', 1), + array( + 'foo', + 'reset', + self::callback(function ($time) { + return $time > 10; + }) + ), + array('foo', 'blocked', 1) + ); + $client->expects(self::once()) + ->method('expire') + ->with('foo', 100); + + $rateLimitInfo = new RateLimitInfo(); + $rateLimitInfo->setKey('foo'); + $rateLimitInfo->setResetTimestamp(10); + $rateLimitInfo->setLimit(2); + $rateLimitInfo->setCalls(1); + + $storage = new Redis($client); + self::assertTrue($storage->setBlock($rateLimitInfo, 100)); + self::assertTrue($rateLimitInfo->isBlocked()); + self::assertGreaterThan(10, $rateLimitInfo->getResetTimestamp()); + } } diff --git a/Tests/Service/Storage/SimpleCacheTest.php b/Tests/Service/Storage/SimpleCacheTest.php index 1e2ae9d..9c62809 100644 --- a/Tests/Service/Storage/SimpleCacheTest.php +++ b/Tests/Service/Storage/SimpleCacheTest.php @@ -2,6 +2,7 @@ namespace Noxlogic\RateLimitBundle\Tests\Service\Storage; +use Noxlogic\RateLimitBundle\Service\RateLimitInfo; use Noxlogic\RateLimitBundle\Service\Storage\SimpleCache; use Noxlogic\RateLimitBundle\Tests\TestCase; @@ -80,4 +81,36 @@ public function testResetRate() $storage = new SimpleCache($client); $this->assertTrue($storage->resetRate('foo')); } + + public function testSetBlock() + { + $client = $this->getMockBuilder('Psr\SimpleCache\CacheInterface')->getMock(); + + $rateLimitInfo = new RateLimitInfo(); + $rateLimitInfo->setKey('foo'); + $rateLimitInfo->setCalls(1); + $rateLimitInfo->setLimit(2); + $rateLimitInfo->setResetTimestamp(time()); + + $periodBlock = 100; + $resetTimestamp = time() + $periodBlock; + $client->expects(self::once()) + ->method('set') + ->with( + 'foo', + [ + 'limit' => $rateLimitInfo->getLimit(), + 'calls' => $rateLimitInfo->getCalls(), + 'reset' => $resetTimestamp, + 'blocked' => 1 + ], + $periodBlock + ) + ->willReturn(true); + + $storage = new SimpleCache($client); + self::assertTrue($storage->setBlock($rateLimitInfo, $periodBlock), 'Result of setting the block must equal true'); + self::assertTrue($rateLimitInfo->isBlocked(), 'After setting the block RateLimitInfo must contain blocked=true'); + self::assertEquals($resetTimestamp, $rateLimitInfo->getResetTimestamp()); + } } diff --git a/composer.lock b/composer.lock index a73279a..44030dc 100644 --- a/composer.lock +++ b/composer.lock @@ -1,7 +1,7 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], "content-hash": "cd266b8d08e70ac6a28aa1a53365fc45",