diff --git a/EventListener/HeaderModificationListener.php b/EventListener/HeaderModificationListener.php index db89178..065757b 100644 --- a/EventListener/HeaderModificationListener.php +++ b/EventListener/HeaderModificationListener.php @@ -35,15 +35,9 @@ public function onKernelResponse(FilterResponseEvent $event) } /** @var RateLimitInfo $rateLimitInfo */ - - $remaining = $rateLimitInfo->getLimit() - $rateLimitInfo->getCalls(); - if ($remaining < 0) { - $remaining = 0; - } - $response = $event->getResponse(); $response->headers->set($this->getParameter('header_limit_name'), $rateLimitInfo->getLimit()); - $response->headers->set($this->getParameter('header_remaining_name'), $remaining); + $response->headers->set($this->getParameter('header_remaining_name'), $rateLimitInfo->getRemainingAttempts()); $response->headers->set($this->getParameter('header_reset_name'), $rateLimitInfo->getResetTimestamp()); } } diff --git a/EventListener/RateLimitAnnotationListener.php b/EventListener/RateLimitAnnotationListener.php index 00fc499..76558dc 100644 --- a/EventListener/RateLimitAnnotationListener.php +++ b/EventListener/RateLimitAnnotationListener.php @@ -7,14 +7,15 @@ use Noxlogic\RateLimitBundle\Events\GenerateKeyEvent; use Noxlogic\RateLimitBundle\Events\RateLimitEvents; use Noxlogic\RateLimitBundle\Exception\RateLimitExceptionInterface; +use Noxlogic\RateLimitBundle\LimitProcessorInterface; use Noxlogic\RateLimitBundle\Service\RateLimitService; +use Noxlogic\RateLimitBundle\Util\AnnotationLimitProcessor; use Noxlogic\RateLimitBundle\Util\PathLimitProcessor; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\FilterControllerEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\Routing\Route; class RateLimitAnnotationListener extends BaseListener { @@ -35,7 +36,9 @@ class RateLimitAnnotationListener extends BaseListener protected $pathLimitProcessor; /** - * @param RateLimitService $rateLimitService + * @param EventDispatcherInterface $eventDispatcher + * @param RateLimitService $rateLimitService + * @param PathLimitProcessor $pathLimitProcessor */ public function __construct( EventDispatcherInterface $eventDispatcher, @@ -64,7 +67,13 @@ public function onKernelController(FilterControllerEvent $event) // Find the best match $annotations = $event->getRequest()->attributes->get('_x-rate-limit', array()); - $rateLimit = $this->findBestMethodMatch($event->getRequest(), $annotations); + + $limitProcessor = $this->pathLimitProcessor; + if ($annotations) { + $limitProcessor = new AnnotationLimitProcessor($annotations, $event->getController()); + } + + $rateLimit = $limitProcessor->getRateLimit($event->getRequest()); // Another treatment before applying RateLimit ? $checkedRateLimitEvent = new CheckedRateLimitEvent($event->getRequest(), $rateLimit); @@ -76,38 +85,21 @@ public function onKernelController(FilterControllerEvent $event) return; } - $key = $this->getKey($event, $rateLimit, $annotations); + $key = $this->getKey($limitProcessor, $rateLimit, $event->getRequest()); - // Ratelimit the call - $rateLimitInfo = $this->rateLimitService->limitRate($key); - if (! $rateLimitInfo) { - // Create new rate limit entry for this call - $rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod()); - if (! $rateLimitInfo) { - // @codeCoverageIgnoreStart - return; - // @codeCoverageIgnoreEnd - } + $rateLimitInfo = $this->rateLimitService->getRateLimitInfo($key, $rateLimit); + if (!$rateLimitInfo) { + // @codeCoverageIgnoreStart + return; + // @codeCoverageIgnoreEnd } - // Store the current rating info in the request attributes $request = $event->getRequest(); $request->attributes->set('rate_limit_info', $rateLimitInfo); - // Reset the rate limits - if(time() >= $rateLimitInfo->getResetTimestamp()) { - $this->rateLimitService->resetRate($key); - $rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod()); - if (! $rateLimitInfo) { - // @codeCoverageIgnoreStart - return; - // @codeCoverageIgnoreEnd - } - } - // When we exceeded our limit, return a custom error response - if ($rateLimitInfo->getCalls() > $rateLimitInfo->getLimit()) { + if ($rateLimitInfo->isExceeded()) { // Throw an exception if configured. if ($this->getParameter('rate_response_exception')) { @@ -134,12 +126,17 @@ public function onKernelController(FilterControllerEvent $event) } - /** + * @param Request $request * @param RateLimit[] $annotations + * @return RateLimit|null + * + * @deprecated since 1.15, use the "\Noxlogic\RateLimitBundle\LimitProcessorInterface::getRateLimit()" method instead. */ protected function findBestMethodMatch(Request $request, array $annotations) { + @trigger_error(sprintf('The "%s()" method is deprecated since version 1.15, use the "\Noxlogic\RateLimitBundle\LimitProcessorInterface::getRateLimit()" method instead.', __METHOD__), E_USER_DEPRECATED); + // Empty array, check the path limits if (count($annotations) == 0) { return $this->pathLimitProcessor->getRateLimit($request); @@ -163,48 +160,16 @@ protected function findBestMethodMatch(Request $request, array $annotations) return $best_match; } - private function getKey(FilterControllerEvent $event, RateLimit $rateLimit, array $annotations) + private function getKey(LimitProcessorInterface $limitProcessor, RateLimit $rateLimit, Request $request) { // Let listeners manipulate the key - $keyEvent = new GenerateKeyEvent($event->getRequest(), '', $rateLimit->getPayload()); + $keyEvent = new GenerateKeyEvent($request, '', $rateLimit->getPayload()); - $rateLimitMethods = join('.', $rateLimit->getMethods()); - $keyEvent->addToKey($rateLimitMethods); - - $rateLimitAlias = count($annotations) === 0 - ? str_replace('/', '.', $this->pathLimitProcessor->getMatchedPath($event->getRequest())) - : $this->getAliasForRequest($event); - $keyEvent->addToKey($rateLimitAlias); + $keyEvent->addToKey(join('.', $rateLimit->getMethods())); + $keyEvent->addToKey($limitProcessor->getRateLimitAlias($request)); $this->eventDispatcher->dispatch(RateLimitEvents::GENERATE_KEY, $keyEvent); return $keyEvent->getKey(); } - - private function getAliasForRequest(FilterControllerEvent $event) - { - if (($route = $event->getRequest()->attributes->get('_route'))) { - return $route; - } - - $controller = $event->getController(); - - if (is_string($controller) && false !== strpos($controller, '::')) { - $controller = explode('::', $controller); - } - - if (is_array($controller)) { - return str_replace('\\', '.', is_string($controller[0]) ? $controller[0] : get_class($controller[0])) . '.' . $controller[1]; - } - - if ($controller instanceof \Closure) { - return 'closure'; - } - - if (is_object($controller)) { - return str_replace('\\', '.', get_class($controller[0])); - } - - return 'other'; - } } diff --git a/LimitProcessorInterface.php b/LimitProcessorInterface.php new file mode 100644 index 0000000..469a045 --- /dev/null +++ b/LimitProcessorInterface.php @@ -0,0 +1,21 @@ +resetTimestamp = $resetTimestamp; } + + /** + * @return int + */ + public function getRemainingAttempts() + { + $remaining = $this->getLimit() - $this->getCalls(); + if ($remaining < 0) { + $remaining = 0; + } + + return $remaining; + } + + /** + * @return bool + */ + public function isExceeded() + { + return $this->getCalls() > $this->getLimit(); + } } diff --git a/Service/RateLimitService.php b/Service/RateLimitService.php index 1dcf891..e7787e0 100644 --- a/Service/RateLimitService.php +++ b/Service/RateLimitService.php @@ -2,6 +2,7 @@ namespace Noxlogic\RateLimitBundle\Service; +use Noxlogic\RateLimitBundle\Annotation\RateLimit; use Noxlogic\RateLimitBundle\Service\Storage\StorageInterface; class RateLimitService @@ -54,4 +55,27 @@ public function resetRate($key) { return $this->storage->resetRate($key); } + + + /** + * @param string $key + * @param RateLimit $rateLimit + * @return RateLimitInfo|null + */ + public function getRateLimitInfo($key, RateLimit $rateLimit) + { + $rateLimitInfo = $this->limitRate($key); + if (!$rateLimitInfo) { + // Create new rate limit entry for this call + return $this->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod()); + } + + // Reset the rate limits + if (time() >= $rateLimitInfo->getResetTimestamp()) { + $this->resetRate($key); + $rateLimitInfo = $this->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod()); + } + + return $rateLimitInfo; + } } diff --git a/Tests/EventListener/MockStorage.php b/Tests/EventListener/MockStorage.php index 56d8a92..a7402ca 100644 --- a/Tests/EventListener/MockStorage.php +++ b/Tests/EventListener/MockStorage.php @@ -66,9 +66,9 @@ public function resetRate($key) unset($this->rates[$key]); } - public function createMockRate($key, $limit, $period, $calls) + public function createMockRate($key, $limit, $period, $calls, $resetTime = null) { - $this->rates[$key] = array('calls' => $calls, 'limit' => $limit, 'reset' => (time() + $period)); + $this->rates[$key] = array('calls' => $calls, 'limit' => $limit, 'reset' => $resetTime ? $resetTime : (time() + $period)); return $this->getRateInfo($key); } } diff --git a/Tests/EventListener/RateLimitAnnotationListenerTest.php b/Tests/EventListener/RateLimitAnnotationListenerTest.php index ffadf97..7a8939b 100644 --- a/Tests/EventListener/RateLimitAnnotationListenerTest.php +++ b/Tests/EventListener/RateLimitAnnotationListenerTest.php @@ -4,7 +4,6 @@ use Noxlogic\RateLimitBundle\Annotation\RateLimit; use Noxlogic\RateLimitBundle\EventListener\RateLimitAnnotationListener; -use Noxlogic\RateLimitBundle\Events\GenerateKeyEvent; use Noxlogic\RateLimitBundle\Events\RateLimitEvents; use Noxlogic\RateLimitBundle\Service\RateLimitService; use Noxlogic\RateLimitBundle\Tests\EventListener\MockStorage; diff --git a/Tests/Service/RateLimitInfoTest.php b/Tests/Service/RateLimitInfoTest.php index 7412913..41c3dc6 100644 --- a/Tests/Service/RateLimitInfoTest.php +++ b/Tests/Service/RateLimitInfoTest.php @@ -2,15 +2,11 @@ namespace Noxlogic\RateLimitBundle\Tests\Annotation; -use Noxlogic\RateLimitBundle\EventListener\OauthKeyGenerateListener; -use Noxlogic\RateLimitBundle\Events\GenerateKeyEvent; use Noxlogic\RateLimitBundle\Service\RateLimitInfo; use Noxlogic\RateLimitBundle\Tests\TestCase; -use Symfony\Component\HttpFoundation\Request; class RateLimitInfoTest extends TestCase { - public function testRateInfoSetters() { $rateInfo = new RateLimitInfo(); @@ -25,4 +21,41 @@ public function testRateInfoSetters() $this->assertEquals(100000, $rateInfo->getResetTimestamp()); } + public function testRemainingAttempts() + { + $rateInfo = new RateLimitInfo(); + + $rateInfo->setLimit(10); + $rateInfo->setCalls(9); + $this->assertEquals(1, $rateInfo->getRemainingAttempts()); + + $rateInfo->setLimit(10); + $rateInfo->setCalls(10); + $this->assertEquals(0, $rateInfo->getRemainingAttempts()); + + $rateInfo->setLimit(10); + $rateInfo->setCalls(20); + $this->assertEquals(0, $rateInfo->getRemainingAttempts()); + } + + public function testIsExceededLimit() + { + $rateInfo = new RateLimitInfo(); + + $rateInfo->setLimit(10); + $rateInfo->setCalls(9); + $this->assertFalse($rateInfo->isExceeded()); + + $rateInfo->setLimit(10); + $rateInfo->setCalls(10); + $this->assertFalse($rateInfo->isExceeded()); + + $rateInfo->setLimit(10); + $rateInfo->setCalls(11); + $this->assertTrue($rateInfo->isExceeded()); + + $rateInfo->setLimit(10); + $rateInfo->setCalls(20); + $this->assertTrue($rateInfo->isExceeded()); + } } diff --git a/Tests/Service/RateLimitServiceTest.php b/Tests/Service/RateLimitServiceTest.php index 39c109b..3ecd0a4 100644 --- a/Tests/Service/RateLimitServiceTest.php +++ b/Tests/Service/RateLimitServiceTest.php @@ -2,12 +2,10 @@ namespace Noxlogic\RateLimitBundle\Tests\Annotation; -use Noxlogic\RateLimitBundle\EventListener\OauthKeyGenerateListener; -use Noxlogic\RateLimitBundle\Events\GenerateKeyEvent; -use Noxlogic\RateLimitBundle\Service\RateLimitInfo; +use Noxlogic\RateLimitBundle\Annotation\RateLimit; use Noxlogic\RateLimitBundle\Service\RateLimitService; +use Noxlogic\RateLimitBundle\Tests\EventListener\MockStorage; use Noxlogic\RateLimitBundle\Tests\TestCase; -use Symfony\Component\HttpFoundation\Request; class RateLimitServiceTest extends TestCase { @@ -70,4 +68,53 @@ public function testResetRate() $service->setStorage($mockStorage); $service->resetRate('testkey'); } + + public function testNoRateLimitInStorage() + { + $rateLimitService = new RateLimitService(); + $rateLimitService->setStorage(new MockStorage()); + + $rateLimit = new RateLimit(array('methods' => 'POST', 'limit' => 1234, 'period' => 1000)); + + $rateLimitInfo = $rateLimitService->getRateLimitInfo('api', $rateLimit); + + $this->assertInstanceOf('Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo', $rateLimitInfo); + $this->assertEquals(1, $rateLimitInfo->getCalls()); + $this->assertEquals(1234, $rateLimitInfo->getLimit()); + $this->assertLessThanOrEqual(time() + 1000, $rateLimitInfo->getResetTimestamp()); + } + + public function testRateLimitInfoExistsInStorage() + { + $rateLimitService = new RateLimitService(); + $mockStorage = new MockStorage(); + $storageRateLimitInfo = $mockStorage->createMockRate('api', 1234, 1000, 800); + $rateLimitService->setStorage($mockStorage); + + $rateLimit = new RateLimit(array('methods' => 'POST', 'limit' => 1234, 'period' => 1000)); + + $rateLimitInfo = $rateLimitService->getRateLimitInfo('api', $rateLimit); + + $storageRateLimitInfo->setCalls(801); + $this->assertEquals($storageRateLimitInfo, $rateLimitInfo); + } + + public function testRateLimitInfoResetCauseGreater() + { + $rateLimitService = new RateLimitService(); + $mockStorage = new MockStorage(); + $storageRateLimitInfo = $mockStorage->createMockRate('api', 1234, 1000, 800, time() - 1); + $rateLimitService->setStorage($mockStorage); + + $rateLimit = new RateLimit(array('methods' => 'POST', 'limit' => 1234, 'period' => 1000)); + + $rateLimitInfo = $rateLimitService->getRateLimitInfo('api', $rateLimit); + + $this->assertNotEquals($storageRateLimitInfo, $rateLimitInfo); + + //New rateLimitInfo created + $this->assertInstanceOf('Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo', $rateLimitInfo); + $this->assertEquals(1, $rateLimitInfo->getCalls()); + $this->assertEquals(1234, $rateLimitInfo->getLimit()); + } } diff --git a/Tests/Util/AnnotationLimitProcessorGetAliasTest.php b/Tests/Util/AnnotationLimitProcessorGetAliasTest.php new file mode 100644 index 0000000..f85d67a --- /dev/null +++ b/Tests/Util/AnnotationLimitProcessorGetAliasTest.php @@ -0,0 +1,116 @@ +attributes->set('_route', 'api.users'); + + $this->assertEquals('api.users', $annotationPathLimit->getRateLimitAlias($request)); + } + + /** + * @dataProvider provideControllerCallables + * + * @param $testName + * @param $controllerCallable + * @param $expected + */ + public function testGetRateLimitAliasControllerCallable($testName, $controllerCallable, $expected) + { + $annotationProcessor = new AnnotationLimitProcessor(array(), $controllerCallable); + $this->assertEquals($expected, $annotationProcessor->getRateLimitAlias(new Request()), $testName); + } + + public function provideControllerCallables() + { + //Controller examples taken from Symfony\Component\HttpKernel\Tests\DataCollector\RequestDataCollectorTest + return array( + array( + '"Regular" callable', + array($this, 'testControllerInspection'), + 'Tests.Util.AnnotationLimitProcessorGetAliasTest.testControllerInspection', + ), + + array( + 'Closure', + function () {}, + 'closure', + ), + + array( + 'Static callback as string', + 'Tests\Util\AnnotationLimitProcessorGetAliasTest::staticControllerMethod', + 'Tests.Util.AnnotationLimitProcessorGetAliasTest.staticControllerMethod', + ), + + array( + 'Static callable with instance', + array($this, 'staticControllerMethod'), + 'Tests.Util.AnnotationLimitProcessorGetAliasTest.staticControllerMethod', + ), + + array( + 'Static callable with class name', + array('Tests\Util\AnnotationLimitProcessorGetAliasTest', 'staticControllerMethod'), + 'Tests.Util.AnnotationLimitProcessorGetAliasTest.staticControllerMethod', + ), + + array( + 'Callable with instance depending on __call()', + array($this, 'magicMethod'), + 'Tests.Util.AnnotationLimitProcessorGetAliasTest.magicMethod', + ), + + array( + 'Callable with class name depending on __callStatic()', + array('Tests\Util\AnnotationLimitProcessorGetAliasTest', 'magicMethod'), + 'Tests.Util.AnnotationLimitProcessorGetAliasTest.magicMethod', + ), + + array( + 'Invokable controller', + $this, + 'Tests.Util.AnnotationLimitProcessorGetAliasTest', + ), + ); + } + + /** + * Dummy method used as controller callable. + */ + public static function staticControllerMethod() + { + throw new LogicException('Unexpected method call'); + } + + /** + * Magic method to allow non existing methods to be called and delegated. + */ + public function __call($method, $args) + { + throw new LogicException('Unexpected method call'); + } + + /** + * Magic method to allow non existing methods to be called and delegated. + */ + public static function __callStatic($method, $args) + { + throw new LogicException('Unexpected method call'); + } + + public function __invoke() + { + throw new LogicException('Unexpected method call'); + } +} diff --git a/Tests/Util/AnnotationLimitProcessorGetRateLimitTest.php b/Tests/Util/AnnotationLimitProcessorGetRateLimitTest.php new file mode 100644 index 0000000..a5df6a3 --- /dev/null +++ b/Tests/Util/AnnotationLimitProcessorGetRateLimitTest.php @@ -0,0 +1,110 @@ + 'GET', 'limit' => 100, 'period' => 3600)), + ); + + $annotationLimitProcess = $this->getAnnotationLimitProcessor($annotations); + + $request->setMethod('PUT'); + $this->assertNull($annotationLimitProcess->getRateLimit($request)); + + $request->setMethod('GET'); + $this->assertEquals( + $annotations[0], + $annotationLimitProcess->getRateLimit($request) + ); + } + + public function testGetRateLimitMatchingMultipleAnnotations() + { + $request = new Request(); + + $annotations = array( + new RateLimit(array('methods' => 'GET', 'limit' => 100, 'period' => 3600)), + new RateLimit(array('methods' => array('GET','PUT'), 'limit' => 200, 'period' => 7200)), + ); + + $annotationLimitProcess = $this->getAnnotationLimitProcessor($annotations); + + $request->setMethod('PUT'); + $this->assertEquals($annotations[1], $annotationLimitProcess->getRateLimit($request)); + + $request->setMethod('GET'); + $this->assertEquals($annotations[1], $annotationLimitProcess->getRateLimit($request)); + } + + public function testBestMethodMatch() + { + $request = new Request(); + + $annotations = array( + new RateLimit(array('limit' => 100, 'period' => 3600)), + new RateLimit(array('methods' => 'GET', 'limit' => 100, 'period' => 3600)), + new RateLimit(array('methods' => array('POST', 'PUT'), 'limit' => 100, 'period' => 3600)), + ); + + $annotationLimitProcess = $this->getAnnotationLimitProcessor($annotations); + + // Find the method that matches the string + $request->setMethod('GET'); + $this->assertEquals( + $annotations[1], + $annotationLimitProcess->getRateLimit($request) + ); + + // Method not found, use the default one + $request->setMethod('DELETE'); + $this->assertEquals( + $annotations[0], + $annotationLimitProcess->getRateLimit($request) + ); + + // Find best match based in methods in array + $request->setMethod('PUT'); + $this->assertEquals( + $annotations[2], + $annotationLimitProcess->getRateLimit($request) + ); + } + + public function testFindNoAnnotations() + { + $request = new Request(); + + $annotations = array(); + + $annotationLimitProcess = $this->getAnnotationLimitProcessor($annotations); + + $request->setMethod('PUT'); + $this->assertNull($annotationLimitProcess->getRateLimit($request)); + + $request->setMethod('GET'); + $this->assertNull($annotationLimitProcess->getRateLimit($request)); + } +} diff --git a/Tests/Util/PathLimitProcessorTest.php b/Tests/Util/PathLimitProcessorTest.php index 99b5dbb..749cb98 100644 --- a/Tests/Util/PathLimitProcessorTest.php +++ b/Tests/Util/PathLimitProcessorTest.php @@ -272,6 +272,25 @@ function itReturnsTheMatchedPath() $this->assertEquals('api', $path); } + /** @test */ + function itReturnsTheMatchedPathAlias() + { + $plp = new PathLimitProcessor(array( + 'api' => array( + 'path' => 'api/users/email', + 'methods' => array('GET', 'POST'), + 'limit' => 1000, + 'period' => 600 + ) + )); + + $path = $plp->getRateLimitAlias( + Request::create('/api/users/email', 'POST') + ); + + $this->assertEquals('api.users.email', $path); + } + /** @test */ function itReturnsTheCorrectPathForADifferentSetup() { @@ -297,12 +316,37 @@ function itReturnsTheCorrectPathForADifferentSetup() $this->assertEquals('api/users/emails', $path); } + /** @test */ + function itReturnsTheCorrectPathAliasForADifferentSetup() + { + $plp = new PathLimitProcessor(array( + 'api' => array( + 'path' => 'api', + 'methods' => array('GET'), + 'limit' => 5, + 'period' => 1 + ), + 'api_emails' => array( + 'path' => 'api/users/emails', + 'methods' => array('GET'), + 'limit' => 100, + 'period' => 60 + ) + )); + + $path = $plp->getRateLimitAlias( + Request::create('/api/users/emails', 'GET') + ); + + $this->assertEquals('api.users.emails', $path); + } + /** @test */ function itReturnsTheCorrectMatchedPathForSubPaths() { $plp = new PathLimitProcessor(array( 'api' => array( - 'path' => 'api/', + 'path' => 'api/users', 'methods' => array('GET'), 'limit' => 100, 'period' => 60 @@ -313,7 +357,25 @@ function itReturnsTheCorrectMatchedPathForSubPaths() Request::create('/api/users/emails', 'GET') ); - $this->assertEquals('api', $path); + $this->assertEquals('api/users', $path); + } + + /** @test */ + function itReturnsTheCorrectMatchedPathAliasForSubPaths() + { + $plp = new PathLimitProcessor(array( + 'api' => array( + 'path' => 'api/users', + 'methods' => array('GET'), + 'limit' => 100, + 'period' => 60 + ) + )); + + $path = $plp->getRateLimitAlias( + Request::create('/api/users/emails', 'GET') + ); + + $this->assertEquals('api.users', $path); } } - \ No newline at end of file diff --git a/Util/AnnotationLimitProcessor.php b/Util/AnnotationLimitProcessor.php new file mode 100644 index 0000000..1155356 --- /dev/null +++ b/Util/AnnotationLimitProcessor.php @@ -0,0 +1,73 @@ +annotations = $annotations; + $this->controller = $controller; + } + + public function getRateLimit(Request $request) + { + $best_match = null; + foreach ($this->annotations as $annotation) { + // cast methods to array, even method holds a string + $methods = is_array($annotation->getMethods()) ? $annotation->getMethods() : array($annotation->getMethods()); + + if (in_array($request->getMethod(), $methods)) { + $best_match = $annotation; + } + + // Only match "default" annotation when we don't have a best match + if (count($annotation->getMethods()) == 0 && $best_match == null) { + $best_match = $annotation; + } + } + + return $best_match; + } + + public function getRateLimitAlias(Request $request) + { + if (($route = $request->attributes->get('_route'))) { + return $route; + } + + $controller = $this->controller; + + if (is_string($controller) && false !== strpos($controller, '::')) { + $controller = explode('::', $controller); + } + + if (is_array($controller)) { + return str_replace('\\', '.', is_string($controller[0]) ? $controller[0] : get_class($controller[0])) . '.' . $controller[1]; + } + + if ($controller instanceof Closure) { + return 'closure'; + } + + if (is_object($controller)) { + return str_replace('\\', '.', get_class($controller)); + } + + return 'other'; + } +} diff --git a/Util/PathLimitProcessor.php b/Util/PathLimitProcessor.php index ebe4723..e77545e 100644 --- a/Util/PathLimitProcessor.php +++ b/Util/PathLimitProcessor.php @@ -1,12 +1,12 @@ getPathInfo(), '/'); - $method = $request->getMethod(); - - foreach ($this->pathLimits as $pathLimit) { - if ($this->requestMatched($pathLimit, $path, $method)) { - return $pathLimit['path']; - } - } + @trigger_error(sprintf('The "%s()" method is deprecated since version 1.15, use the "getRateLimitAlias()" method instead.', __METHOD__), E_USER_DEPRECATED); + return $this->getMatchedLimitPath($request); + } - return ''; + public function getRateLimitAlias(Request $request) + { + return str_replace('/', '.', $this->getMatchedLimitPath($request)); } private function requestMatched($pathLimit, $path, $method) @@ -92,4 +95,22 @@ private function pathMatched($expectedPath, $path) return true; } -} \ No newline at end of file + + /** + * @param Request $request + * @return string + */ + private function getMatchedLimitPath(Request $request) + { + $path = trim($request->getPathInfo(), '/'); + $method = $request->getMethod(); + + foreach ($this->pathLimits as $pathLimit) { + if ($this->requestMatched($pathLimit, $path, $method)) { + return $pathLimit['path']; + } + } + + return ''; + } +} diff --git a/docs/laravel-middleware-example.md b/docs/laravel-middleware-example.md new file mode 100644 index 0000000..92799fb --- /dev/null +++ b/docs/laravel-middleware-example.md @@ -0,0 +1,131 @@ +Laravel comes with ``Illuminate\Routing\Middleware\ThrottleRequests`` middleware. +Those solution developed to be declared only once in application, applying it twice brings unexpected behavior in headers response. + +Example replace ``Illuminate\Routing\Middleware\ThrottleRequests`` with ``Noxlogic\RateLimitBundle`` based on configuration file rate limit rules, +repeating default ``Illuminate\Routing\Middleware\ThrottleRequests`` behaviour with blocking by client ip. + +``rate-limit.php`` +```php + [ + 'path' => 'api/', + 'methods' => ['*'], + 'limit' => 60, + 'period' => 60 + ] +]; +``` + +``RateLimit.php`` +```php +setStorage(new Redis($redisFactory->client())); + $this->rateLimitService = $rateLimitService; + $this->pathLimitProcessor = new PathLimitProcessor(config('rate-limit')); + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + $rateLimit = $this->pathLimitProcessor->getRateLimit($request); + + $key = trim(join('.', $rateLimit->getMethods()) . '.' . $this->pathLimitProcessor->getRateLimitAlias($request) . '.' . $request->getClientIp(), '.'); + + $rateLimitInfo = $this->rateLimitService->getRateLimitInfo($key, $rateLimit); + + // When we exceeded our limit, return a custom error response + if ($rateLimitInfo->isExceeded()) { + throw new ThrottleRequestsException( + 'Too Many Attempts.', + null, + $this->getHeaders($rateLimitInfo) + ); + } + + $response = $next($request); + + return $this->addHeaders($response, $rateLimitInfo); + } + + /** + * Add the limit header information to the given response. + * + * @param Response $response + * @param RateLimitInfo $rateLimitInfo + * @return Response + */ + protected function addHeaders(Response $response, RateLimitInfo $rateLimitInfo) + { + $response->headers->add( + $this->getHeaders($rateLimitInfo) + ); + + return $response; + } + + /** + * Get the limit headers information. + * + * @param RateLimitInfo $rateLimitInfo + * @return array + */ + protected function getHeaders(RateLimitInfo $rateLimitInfo) + { + return [ + 'X-RateLimit-Limit' => $rateLimitInfo->getLimit(), + 'X-RateLimit-Remaining' => $rateLimitInfo->getRemainingAttempts(), + 'Retry-After' => $rateLimitInfo->getResetTimestamp() - time(), + 'X-RateLimit-Reset' => $rateLimitInfo->getResetTimestamp() + ]; + } +} +``` +``Kernel.php`` +```php + protected $middlewareGroups = [ + 'api' => [ + //'throttle:60,1', + 'rate-limit', + ], + ]; + + protected $routeMiddleware = [ + //'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'rate-limit' => \Project\Middlewares\RateLimit::class, + ]; +```