diff --git a/src/Http/RateLimit/Exception/LimitExceededException.php b/src/Http/RateLimit/Exception/LimitExceededException.php new file mode 100644 index 0000000..612a054 --- /dev/null +++ b/src/Http/RateLimit/Exception/LimitExceededException.php @@ -0,0 +1,94 @@ +identifier = $identifier; + $exception->rate = $rate; + + return $exception; + } + + /** + * Return the identifier + * @return string + */ + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * Return the rate + * @return Rate + */ + public function getRate(): Rate + { + return $this->rate; + } +} diff --git a/src/Http/RateLimit/Rate.php b/src/Http/RateLimit/Rate.php new file mode 100644 index 0000000..c7850c7 --- /dev/null +++ b/src/Http/RateLimit/Rate.php @@ -0,0 +1,164 @@ +quota = $quota; + $this->interval = $interval; + } + + /** + * Set quota to be used per second + * @param int $quota + * @return self + */ + public static function perSecond(int $quota): self + { + return new static($quota, 1); + } + + /** + * Set quota to be used per minute + * @param int $quota + * @return self + */ + public static function perMinute(int $quota): self + { + return new static($quota, 60); + } + + /** + * Set quota to be used per hour + * @param int $quota + * @return self + */ + public static function perHour(int $quota): self + { + return new static($quota, 3600); + } + + /** + * Set quota to be used per day + * @param int $quota + * @return self + */ + public static function perDay(int $quota): self + { + return new static($quota, 86400); + } + + /** + * Set custom quota and interval + * @param int $quota + * @param int $interval + * @return self + */ + public static function custom(int $quota, int $interval): self + { + return new static($quota, $interval); + } + + /** + * Return the rate limit quota + * @return int + */ + public function getQuota(): int + { + return $this->quota; + } + + /** + * Return the interval in second + * @return int + */ + public function getInterval(): int + { + return $this->interval; + } +} diff --git a/src/Http/RateLimit/RateLimit.php b/src/Http/RateLimit/RateLimit.php new file mode 100644 index 0000000..949101e --- /dev/null +++ b/src/Http/RateLimit/RateLimit.php @@ -0,0 +1,193 @@ +storage = $storage; + $this->rate = $rate; + $this->name = $name; + } + + /** + * Rate Limiting + * @param string $id + * @param float $used + * @return void + */ + public function limit(string $id, float $used = 1.0): void + { + $rate = $this->rate->getQuota() / $this->rate->getInterval(); + $allowKey = $this->getAllowKey($id); + $timeKey = $this->getTimeKey($id); + + if ($this->storage->exists($timeKey) === false) { + // first hit; setup storage; allow. + $this->storage->set($timeKey, time(), $this->rate->getInterval()); + $this->storage->set($allowKey, ($this->rate->getQuota() - $used), $this->rate->getInterval()); + + return; + } + + $currentTime = time(); + $timePassed = $currentTime - $this->storage->get($timeKey); + $this->storage->set($timeKey, $currentTime, $this->rate->getInterval()); + + $quota = $this->storage->get($allowKey); + $quota += $timePassed * $rate; + + + + if ($quota > $this->rate->getQuota()) { + $quota = $this->rate->getQuota(); // throttle + } + + if ($quota < $used) { + // need to wait for more 'tokens' to be in the bucket. + $this->storage->set($allowKey, $quota, $this->rate->getInterval()); + + throw LimitExceededException::create($id, $this->rate); + } + + $this->storage->set($allowKey, $quota - $used, $this->rate->getInterval()); + } + + /** + * Return the remaining quota to be used + * @param string $id + * @return int the number of operation that can be made before hitting a limit. + */ + public function getRemainingAttempts(string $id): int + { + $this->limit($id, 0.0); + + $allowKey = $this->getAllowKey($id); + + if ($this->storage->exists($allowKey) === false) { + return $this->rate->getQuota(); + } + + return (int) max(0, floor($this->storage->get($allowKey))); + } + + /** + * Purge rate limit record for the given key + * @param string $id + * @return void + */ + public function purge(string $id): void + { + $this->storage->delete($this->getAllowKey($id)); + $this->storage->delete($this->getTimeKey($id)); + } + + /** + * Return the storage key used for time of the given identifier + * @param string $id + * @return string + */ + protected function getTimeKey(string $id): string + { + return sprintf( + '%s:%s:time', + $this->name, + $id + ); + } + + /** + * Return the storage key used for allow of the given identifier + * @param string $id + * @return string + */ + protected function getAllowKey(string $id): string + { + return sprintf( + '%s:%s:allow', + $this->name, + $id + ); + } +} diff --git a/src/Http/RateLimit/RateLimitStorageInterface.php b/src/Http/RateLimit/RateLimitStorageInterface.php new file mode 100644 index 0000000..13cc7dd --- /dev/null +++ b/src/Http/RateLimit/RateLimitStorageInterface.php @@ -0,0 +1,85 @@ + + */ + protected array $data = []; + + /** + * {@inheritdoc} + */ + public function set(string $key, float $value, int $ttl): bool + { + $this->data[$key] = [ + 'value' => $value, + 'expire' => time() + $ttl, + ]; + + return true; + } + + /** + * {@inheritdoc} + */ + public function get(string $key): float + { + $values = $this->data[$key] ?? []; + if (count($values) === 0 || $values['expire'] <= time()) { + return 0; + } + + return $values['value']; + } + + /** + * {@inheritdoc} + */ + public function exists(string $key): bool + { + return isset($this->data[$key]); + } + + /** + * {@inheritdoc} + */ + public function delete(string $key): bool + { + unset($this->data[$key]); + + return true; + } +} diff --git a/src/Http/RateLimit/Storage/SessionStorage.php b/src/Http/RateLimit/Storage/SessionStorage.php new file mode 100644 index 0000000..208e79d --- /dev/null +++ b/src/Http/RateLimit/Storage/SessionStorage.php @@ -0,0 +1,115 @@ +session = $session; + } + + /** + * {@inheritdoc} + */ + public function set(string $key, float $value, int $ttl): bool + { + $this->session->set($key, [ + 'value' => $value, + 'expire' => time() + $ttl, + ]); + + return true; + } + + /** + * {@inheritdoc} + */ + public function get(string $key): float + { + $values = $this->session->get($key, []); + if (count($values) === 0 || $values['expire'] <= time()) { + return 0; + } + + return $values['value']; + } + + /** + * {@inheritdoc} + */ + public function exists(string $key): bool + { + return $this->session->has($key); + } + + /** + * {@inheritdoc} + */ + public function delete(string $key): bool + { + return $this->session->remove($key); + } +} diff --git a/tests/Auth/Entity/RoleTest.php b/tests/Auth/Entity/RoleTest.php index 5899b1d..9a249f0 100644 --- a/tests/Auth/Entity/RoleTest.php +++ b/tests/Auth/Entity/RoleTest.php @@ -54,7 +54,7 @@ public function testSetRemovePermissions(): void $this->assertInstanceOf(Role::class, $entity->setPermissions([$permission])); $this->assertInstanceOf(Role::class, $entity->removePermissions([$permission])); } - + public function testSetRemoveUsers(): void { $user = $this->getMockInstance(User::class); diff --git a/tests/Http/RateLimit/Exception/LimitExceededExceptionTest.php b/tests/Http/RateLimit/Exception/LimitExceededExceptionTest.php new file mode 100644 index 0000000..8a261e9 --- /dev/null +++ b/tests/Http/RateLimit/Exception/LimitExceededExceptionTest.php @@ -0,0 +1,25 @@ +assertEquals('api', $ex->getIdentifier()); + $this->assertEquals(100, $ex->getRate()->getQuota()); + $this->assertEquals(3600, $ex->getRate()->getInterval()); + } +} diff --git a/tests/Http/RateLimit/RateLimitTest.php b/tests/Http/RateLimit/RateLimitTest.php new file mode 100644 index 0000000..0f092c4 --- /dev/null +++ b/tests/Http/RateLimit/RateLimitTest.php @@ -0,0 +1,100 @@ +getMockInstance(InMemoryStorage::class); + $o = new RateLimit($storage, Rate::perDay(100), 'api'); + + $this->assertInstanceOf(RateLimit::class, $o); + } + + public function testLimitFirst(): void + { + $storage = $this->getMockInstance(InMemoryStorage::class, [ + 'exists' => false + ]); + $o = new RateLimit($storage, Rate::perDay(100), 'api'); + + $storage->expects($this->exactly(2)) + ->method('set'); + + $o->limit('client_ip'); + } + + public function testGetRemainingAttempts(): void + { + $storage = $this->getMockInstance(InMemoryStorage::class, [ + 'exists' => false + ]); + $o = new RateLimit($storage, Rate::perDay(100), 'api'); + + $this->assertEquals(100, $o->getRemainingAttempts('client_ip')); + } + + public function testLimitQuotaLimitReached(): void + { + $storage = new InMemoryStorage(); + $o = new RateLimit($storage, Rate::perHour(3), 'api'); + + $this->assertEquals(3, $o->getRemainingAttempts('client_ip')); + + $o->limit('client_ip'); + $o->limit('client_ip'); + + $this->assertEquals(1, $o->getRemainingAttempts('client_ip')); + + $this->expectException(LimitExceededException::class); + $this->expectExceptionMessage('Limit has been exceeded for identifier "client_ip"'); + + $o->limit('client_ip', 8); + } + + public function testLimitCalculatedQuotaExceedTheConfig(): void + { + $storage = $this->getMockInstance(InMemoryStorage::class, [ + 'exists' => true, + 'get' => 999999, + ]); + $o = new RateLimit($storage, Rate::perHour(3), 'api'); + + $this->assertEquals(999999, $o->getRemainingAttempts('client_ip')); + + $o->limit('client_ip'); + $o->limit('client_ip'); + + $this->assertEquals(999999, $o->getRemainingAttempts('client_ip')); + } + + + public function testPurge(): void + { + $storage = new InMemoryStorage(); + $o = new RateLimit($storage, Rate::perDay(3), 'api'); + + $this->assertEquals(3, $o->getRemainingAttempts('client_ip')); + + $o->limit('client_ip'); + $o->limit('client_ip'); + + $this->assertEquals(1, $o->getRemainingAttempts('client_ip')); + + $o->purge('client_ip'); + $this->assertEquals(3, $o->getRemainingAttempts('client_ip')); + } +} diff --git a/tests/Http/RateLimit/RateTest.php b/tests/Http/RateLimit/RateTest.php new file mode 100644 index 0000000..cae1f7c --- /dev/null +++ b/tests/Http/RateLimit/RateTest.php @@ -0,0 +1,93 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Quota must be greater than zero, received [0]'); + + Rate::perSecond(0); + } + + public function testInvaliValueOfInterval(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Seconds interval must be greater than zero, received [-1]'); + + Rate::custom(10, -1); + } + + /** + * @dataProvider commonDataProvider + * @param string $method method to call + * @param array $args methods arguments + * @param int $expectedQuota + * @param int $expectedInterval + * @return void + */ + public function testCommonMethods( + string $method, + array $args, + int $expectedQuota, + int $expectedInterval + ): void { + /** @var Rate $rate */ + $rate = Rate::{$method}(...$args); + $this->assertEquals($expectedQuota, $rate->getQuota()); + $this->assertEquals($expectedInterval, $rate->getInterval()); + } + + /** + * Data provider for "testCommonMethods" + * @return array + */ + public function commonDataProvider(): array + { + return [ + [ + 'perSecond', + [13], + 13, + 1 + ], + [ + 'perMinute', + [80], + 80, + 60 + ], + [ + 'perHour', + [17], + 17, + 3600 + ], + [ + 'perDay', + [3], + 3, + 86400 + ], + [ + 'custom', + [13, 50], + 13, + 50 + ], + + ]; + } +} diff --git a/tests/Http/RateLimit/Storage/ApcuStorageTest.php b/tests/Http/RateLimit/Storage/ApcuStorageTest.php new file mode 100644 index 0000000..047f3e1 --- /dev/null +++ b/tests/Http/RateLimit/Storage/ApcuStorageTest.php @@ -0,0 +1,129 @@ +expectException(RuntimeException::class); + + (new ApcuStorage()); + } + + public function testConstructorExtensionIstLoadedButNotEnabled(): void + { + global $mock_extension_loaded_to_true, $mock_ini_get_to_false; + + $mock_extension_loaded_to_true = true; + $mock_ini_get_to_false = true; + + $this->expectException(RuntimeException::class); + + (new ApcuStorage()); + } + + public function testGet(): void + { + global $mock_extension_loaded_to_true, + $mock_ini_get_to_true, + $mock_apcu_fetch_to_false; + + $mock_extension_loaded_to_true = true; + $mock_ini_get_to_true = true; + + $ac = new ApcuStorage(); + + $mock_apcu_fetch_to_false = true; + //Default value + $this->assertEquals(0.0, $ac->get('not_found_key', 'bar')); + + $mock_apcu_fetch_to_false = false; + //Return correct data + $key = uniqid(); + + $content = $ac->get($key); + $this->assertEquals(6, $content); + } + + public function testSetSimple(): void + { + global $mock_extension_loaded_to_true, + $mock_ini_get_to_true, + $mock_apcu_store_to_true; + + $key = uniqid(); + + $mock_extension_loaded_to_true = true; + $mock_ini_get_to_true = true; + $mock_apcu_store_to_true = true; + + + $ac = new ApcuStorage(); + $result = $ac->set($key, 15, 100); + $this->assertTrue($result); + } + + public function testSetFailed(): void + { + global $mock_extension_loaded_to_true, + $mock_ini_get_to_true, + $mock_apcu_store_to_false; + + $mock_extension_loaded_to_true = true; + $mock_ini_get_to_true = true; + $mock_apcu_store_to_false = true; + + $ac = new ApcuStorage(); + $result = $ac->set('key', 100, 100); + $this->assertFalse($result); + } + + public function testDeleteSuccess(): void + { + global $mock_extension_loaded_to_true, + $mock_ini_get_to_true, + $mock_apcu_delete_to_true; + + $mock_extension_loaded_to_true = true; + $mock_ini_get_to_true = true; + $mock_apcu_delete_to_true = true; + + $key = uniqid(); + + $ac = new ApcuStorage(); + + $this->assertTrue($ac->delete($key)); + $this->assertFalse($ac->exists($key)); + } + + public function testDeleteFailed(): void + { + global $mock_extension_loaded_to_true, + $mock_ini_get_to_true, + $mock_apcu_delete_to_false; + + $mock_extension_loaded_to_true = true; + $mock_ini_get_to_true = true; + $mock_apcu_delete_to_false = true; + + $key = uniqid(); + + $ac = new ApcuStorage(); + + $this->assertFalse($ac->delete($key)); + } +} diff --git a/tests/Http/RateLimit/Storage/InMemoryStorageTest.php b/tests/Http/RateLimit/Storage/InMemoryStorageTest.php new file mode 100644 index 0000000..3abb523 --- /dev/null +++ b/tests/Http/RateLimit/Storage/InMemoryStorageTest.php @@ -0,0 +1,36 @@ +assertInstanceOf(InMemoryStorage::class, $o); + } + + public function testAll(): void + { + $o = new InMemoryStorage(); + + $this->assertEquals(0, $o->get('foo')); + $this->assertFalse($o->exists('foo')); + $o->set('foo', 100, 600); + $this->assertEquals(100, $o->get('foo')); + $this->assertTrue($o->exists('foo')); + $this->assertTrue($o->delete('foo')); + $this->assertEquals(0, $o->get('foo')); + $this->assertFalse($o->exists('foo')); + } +} diff --git a/tests/Http/RateLimit/Storage/SessionStorageTest.php b/tests/Http/RateLimit/Storage/SessionStorageTest.php new file mode 100644 index 0000000..4e4897c --- /dev/null +++ b/tests/Http/RateLimit/Storage/SessionStorageTest.php @@ -0,0 +1,72 @@ +getMockInstance(Session::class); + $o = new SessionStorage($session); + + $this->assertInstanceOf(SessionStorage::class, $o); + } + + public function testSet(): void + { + $session = $this->getMockInstance(Session::class); + + $session->expects($this->exactly(1)) + ->method('set'); + + $o = new SessionStorage($session); + + $o->set('foo', 100, 600); + } + + public function testGetNotFound(): void + { + $session = $this->getMockInstance(Session::class, [ + 'get' => [], + 'has' => false + ]); + $o = new SessionStorage($session); + + $this->assertEquals(0, $o->get('foo')); + $this->assertFalse($o->exists('foo')); + } + + public function testGet(): void + { + $session = $this->getMockInstance(Session::class, [ + 'get' => ['expire' => time() + 1000, 'value' => 108], + 'has' => true + ]); + $o = new SessionStorage($session); + + $this->assertEquals(108.0, $o->get('foo')); + $this->assertTrue($o->exists('foo')); + } + + public function testDelete(): void + { + $session = $this->getMockInstance(Session::class); + + $session->expects($this->exactly(1)) + ->method('remove'); + + $o = new SessionStorage($session); + + $o->delete('foo'); + } +} diff --git a/tests/fixtures/mocks.php b/tests/fixtures/mocks.php index 528bcc5..ae20ed4 100644 --- a/tests/fixtures/mocks.php +++ b/tests/fixtures/mocks.php @@ -2,6 +2,98 @@ declare(strict_types=1); +namespace Platine\Framework\Http\RateLimit\Storage; + +$mock_extension_loaded_to_false = false; +$mock_extension_loaded_to_true = false; +$mock_ini_get_to_false = false; +$mock_ini_get_to_true = false; +$mock_apcu_fetch_to_false = false; +$mock_apcu_store_to_false = false; +$mock_apcu_store_to_true = false; +$mock_apcu_delete_to_false = false; +$mock_apcu_delete_to_true = false; +$mock_apcu_exists_to_false = false; +$mock_apcu_exists_to_true = false; + + +function apcu_exists($key): bool +{ + global $mock_apcu_exists_to_false, $mock_apcu_exists_to_true; + if ($mock_apcu_exists_to_false) { + return false; + } elseif ($mock_apcu_exists_to_true) { + return true; + } + + return false; +} + +/** + * @return null|string + */ +function apcu_fetch($key, bool &$success) +{ + global $mock_apcu_fetch_to_false; + if ($mock_apcu_fetch_to_false) { + $success = false; + } else { + $success = true; + return 6; + } +} + +function apcu_store($key, $var, int $ttl = 0): bool +{ + global $mock_apcu_store_to_false, $mock_apcu_store_to_true; + if ($mock_apcu_store_to_false) { + return false; + } elseif ($mock_apcu_store_to_true) { + return true; + } + + return false; +} + +function apcu_delete($key): bool +{ + global $mock_apcu_delete_to_false, $mock_apcu_delete_to_true; + if ($mock_apcu_delete_to_false) { + return false; + } elseif ($mock_apcu_delete_to_true) { + return true; + } + + return false; +} + +function extension_loaded(string $name): bool +{ + global $mock_extension_loaded_to_false, $mock_extension_loaded_to_true; + if ($mock_extension_loaded_to_false) { + return false; + } elseif ($mock_extension_loaded_to_true) { + return true; + } else { + return \extension_loaded($name); + } +} + +/** + * @return bool|string + */ +function ini_get(string $option) +{ + global $mock_ini_get_to_true, $mock_ini_get_to_false; + if ($mock_ini_get_to_false) { + return false; + } elseif ($mock_ini_get_to_true) { + return true; + } else { + return \ini_get($option); + } +} + namespace Platine\Framework\Task; $mock_preg_split_to_false = false;