├── src ├── Time │ ├── TimerInterface.php │ └── MicrotimeTimer.php ├── Policy │ ├── LimitAlways.php │ ├── LimitPerIp.php │ ├── LimitCallback.php │ └── LimitPolicyInterface.php ├── CounterInterface.php ├── Storage │ ├── SimpleCacheStorage.php │ ├── ApcuStorage.php │ └── StorageInterface.php ├── CounterState.php ├── LimitRequestsMiddleware.php └── Counter.php ├── .phpunit-watcher.yml ├── composer-require-checker.json ├── infection.json.dist ├── psalm.xml ├── rector.php ├── UPGRADE.md ├── CHANGELOG.md ├── LICENSE.md ├── .styleci.yml ├── composer.json └── README.md /src/Time/TimerInterface.php: -------------------------------------------------------------------------------- 1 | getMethod() . $request->getUri()->getPath())); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Policy/LimitPerIp.php: -------------------------------------------------------------------------------- 1 | getMethod() . $request->getUri()->getPath() . $this->getIp($request))); 14 | } 15 | 16 | private function getIp(ServerRequestInterface $request): string 17 | { 18 | /** @psalm-var array{REMOTE_ADDR?: string} $server */ 19 | $server = $request->getServerParams(); 20 | 21 | return $server['REMOTE_ADDR'] ?? ''; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Policy/LimitCallback.php: -------------------------------------------------------------------------------- 1 | receiver)($request); 22 | 23 | if (!is_string($id) || '' === $id) { 24 | throw new \InvalidArgumentException('The id must be a non-empty-string.'); 25 | } 26 | 27 | return $id; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | paths([ 12 | __DIR__ . '/src', 13 | __DIR__ . '/tests', 14 | ]); 15 | 16 | // register a single rule 17 | $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class); 18 | 19 | // define sets of rules 20 | $rectorConfig->sets([ 21 | LevelSetList::UP_TO_PHP_80, 22 | ]); 23 | 24 | $rectorConfig->skip([ 25 | ClosureToArrowFunctionRector::class, 26 | ]); 27 | }; 28 | -------------------------------------------------------------------------------- /src/CounterInterface.php: -------------------------------------------------------------------------------- 1 | cache->set($key, $value, $ttl); 21 | } 22 | 23 | public function saveCompareAndSwap(string $key, float $oldValue, float $newValue, int $ttl): bool 24 | { 25 | return $this->cache->set($key, $newValue, $ttl); 26 | } 27 | 28 | public function get(string $key): ?float 29 | { 30 | /** @psalm-suppress MixedAssignment */ 31 | $value = $this->cache->get($key); 32 | 33 | return (is_int($value) || is_float($value)) ? (float) $value : null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrading Instructions for Yii Rate Limiter Middleware 2 | 3 | This file contains the upgrade notes. These notes highlight changes that could break your 4 | application when you upgrade the package from one version to another. 5 | 6 | > **Important!** The following upgrading instructions are cumulative. That is, if you want 7 | > to upgrade from version A to version C and there is version B between A and C, you need 8 | > to following the instructions for both A and B. 9 | 10 | ## Upgrade from 1.x 11 | 12 | In order to switch from version 1 to version 2 you need to update initialization code: 13 | 14 | ```php 15 | $cache = new ArrayCache(); 16 | $counter = new Counter(2, 5, $cache); 17 | $middleware = new Middleware($counter, $responseFactory); 18 | ``` 19 | 20 | to 21 | 22 | ```php 23 | $storage = new SimpleCacheStorage($cache); 24 | $counter = new Counter($storage, 2, 5); 25 | $middleware = new LimitRequestsMiddleware($counter, $responseFactory); 26 | ``` 27 | 28 | Check the readme for new features introduced in version 2. 29 | -------------------------------------------------------------------------------- /src/Policy/LimitPolicyInterface.php: -------------------------------------------------------------------------------- 1 | fixPrecisionRate; 33 | 34 | return apcu_add($key, (int) $value, $ttl); 35 | } 36 | 37 | public function saveCompareAndSwap(string $key, float $oldValue, float $newValue, int $ttl): bool 38 | { 39 | $oldValue *= $this->fixPrecisionRate; 40 | $newValue *= $this->fixPrecisionRate; 41 | 42 | return apcu_cas($key, (int) $oldValue, (int) $newValue); 43 | } 44 | 45 | public function get(string $key): ?float 46 | { 47 | /** @psalm-suppress MixedAssignment */ 48 | $value = apcu_fetch($key); 49 | 50 | return (is_int($value) || is_float($value)) 51 | ? (float) $value / $this->fixPrecisionRate 52 | : null; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/CounterState.php: -------------------------------------------------------------------------------- 1 | limit; 32 | } 33 | 34 | /** 35 | * @return int The number of remaining requests in the current time period. 36 | */ 37 | public function getRemaining(): int 38 | { 39 | return $this->remaining; 40 | } 41 | 42 | /** 43 | * @return int Timestamp to wait until the rate limit resets. 44 | */ 45 | public function getResetTime(): int 46 | { 47 | return $this->resetTime; 48 | } 49 | 50 | /** 51 | * @return bool If requests limit is reached. 52 | */ 53 | public function isLimitReached(): bool 54 | { 55 | return $this->remaining === 0; 56 | } 57 | 58 | /** 59 | * @return bool If fail to store updated the rate limit data. 60 | */ 61 | public function isFailStoreUpdatedData(): bool 62 | { 63 | return $this->isFailStoreUpdatedData; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Storage/StorageInterface.php: -------------------------------------------------------------------------------- 1 | limitingPolicy = $limitingPolicy ?: new LimitPerIp(); 36 | } 37 | 38 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 39 | { 40 | $state = $this->counter->hit($this->limitingPolicy->fingerprint($request)); 41 | 42 | if ($state->isLimitReached()) { 43 | $response = $this->createErrorResponse(); 44 | } elseif ($state->isFailStoreUpdatedData() && $this->failStoreUpdatedDataMiddleware !== null) { 45 | $response = $this->failStoreUpdatedDataMiddleware->process($request, $handler); 46 | } else { 47 | $response = $handler->handle($request); 48 | } 49 | 50 | return $this->addHeaders($response, $state); 51 | } 52 | 53 | private function createErrorResponse(): ResponseInterface 54 | { 55 | $response = $this->responseFactory->createResponse(Status::TOO_MANY_REQUESTS); 56 | $response->getBody()->write(Status::TEXTS[Status::TOO_MANY_REQUESTS]); 57 | 58 | return $response; 59 | } 60 | 61 | private function addHeaders(ResponseInterface $response, CounterState $result): ResponseInterface 62 | { 63 | return $response 64 | ->withHeader('X-Rate-Limit-Limit', (string) $result->getLimit()) 65 | ->withHeader('X-Rate-Limit-Remaining', (string) $result->getRemaining()) 66 | ->withHeader('X-Rate-Limit-Reset', (string) $result->getResetTime()); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Yii 4 | 5 |

Yii Rate Limiter Middleware

6 |
7 |

8 | 9 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/rate-limiter/v/stable.png)](https://packagist.org/packages/yiisoft/rate-limiter) 10 | [![Total Downloads](https://poser.pugx.org/yiisoft/rate-limiter/downloads.png)](https://packagist.org/packages/yiisoft/rate-limiter) 11 | [![Build status](https://github.com/yiisoft/rate-limiter/workflows/build/badge.svg)](https://github.com/yiisoft/rate-limiter/actions?query=workflow%3Abuild) 12 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/yiisoft/rate-limiter/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/rate-limiter/?branch=master) 13 | [![Code Coverage](https://scrutinizer-ci.com/g/yiisoft/rate-limiter/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/rate-limiter/?branch=master) 14 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Frate-limiter%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/rate-limiter/master) 15 | [![static analysis](https://github.com/yiisoft/rate-limiter/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/rate-limiter/actions?query=workflow%3A%22static+analysis%22) 16 | [![type-coverage](https://shepherd.dev/github/yiisoft/rate-limiter/coverage.svg)](https://shepherd.dev/github/yiisoft/rate-limiter) 17 | 18 | Rate limiter middleware helps to prevent abuse by limiting the number of requests that could be me made consequentially. 19 | 20 | For example, you may want to limit the API usage of each user to be at most 100 API calls within a period of 10 minutes. 21 | If too many requests are received from a user within the stated period of the time, a response with status code 429 22 | (meaning "Too Many Requests") should be returned. 23 | 24 | ## Requirements 25 | 26 | - PHP 8.0 or higher. 27 | 28 | ## Installation 29 | 30 | The package could be installed with [Composer](https://getcomposer.org): 31 | 32 | ```shell 33 | composer require yiisoft/rate-limiter 34 | ``` 35 | 36 | ## General usage 37 | 38 | ```php 39 | use Psr\Http\Message\ServerRequestInterface; 40 | use Yiisoft\Yii\RateLimiter\LimitRequestsMiddleware; 41 | use Yiisoft\Yii\RateLimiter\Counter; 42 | use Nyholm\Psr7\Factory\Psr17Factory; 43 | use Yiisoft\Yii\RateLimiter\Policy\LimitAlways; 44 | use Yiisoft\Yii\RateLimiter\Policy\LimitPerIp; 45 | use Yiisoft\Yii\RateLimiter\Policy\LimitCallback; 46 | use Yiisoft\Yii\RateLimiter\Storage\StorageInterface; 47 | use Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage; 48 | 49 | /** @var StorageInterface $storage */ 50 | $storage = new SimpleCacheStorage($cache); 51 | 52 | $counter = new Counter($storage, 2, 5); 53 | $responseFactory = new Psr17Factory(); 54 | 55 | $middleware = new LimitRequestsMiddleware($counter, $responseFactory); // LimitPerIp by default 56 | ``` 57 | 58 | In the above 2 is the maximum number of counter increments (requests) that could be performed before increments 59 | are limited and 5 is a period to apply limit to, in seconds. 60 | 61 | The `Counter` implements [generic cell rate limit algorithm (GCRA)](https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm) 62 | that ensures that after reaching the limit further increments are distributed equally. 63 | 64 | > Note: While it is sufficiently effective, it is preferred to use [Nginx](https://www.nginx.com/blog/rate-limiting-nginx/) 65 | > or another webserver capabilities for rate limiting. This package allows rate-limiting in the project with deployment 66 | > environment you cannot control such as installable CMS. 67 | 68 | ### Implementing your own limiting policy 69 | 70 | There are two ready to use limiting policies available in the package: 71 | 72 | - `LimitAlways` - to count all incoming requests. 73 | - `LimitPerIp` - to count requests from different IPs separately. 74 | 75 | These could be applied as follows: 76 | 77 | ```php 78 | $middleware = new LimitRequestsMiddleware($counter, $responseFactory, new LimitPerIp()); 79 | // or 80 | $middleware = new LimitRequestsMiddleware($counter, $responseFactory, new LimitAlways()); 81 | ``` 82 | 83 | Easiest way to customize a policy is to use `LimitCallback`: 84 | 85 | ```php 86 | $middleware = new LimitRequestsMiddleware($counter, $responseFactory, new LimitCallback(function (ServerRequestInterface $request): string { 87 | // return user id from database if authentication id used i.e. limit guests and each authenticated user separately. 88 | })); 89 | ``` 90 | 91 | Another way it to implement `Yiisoft\Yii\RateLimiter\Policy\LimitPolicyInterface` and use it in a similar way as above. 92 | 93 | ### Implementing your own counter storage 94 | 95 | There are two ready to use counter storages available in the package: 96 | - `\Yiisoft\Yii\RateLimiter\Storage\SimpleCacheStorage` - stores counters in any [PSR-16](https://www.php-fig.org/psr/psr-16/) cache. 97 | - `\Yiisoft\Yii\RateLimiter\Storage\ApcuStorage` - stores counters by using the [APCu PHP extension](https://www.php.net/apcu) while taking concurrency into account. 98 | 99 | To use your own storage implement `Yiisoft\Yii\RateLimiter\Storage\StorageInterface`. 100 | 101 | ## Documentation 102 | 103 | - [Internals](docs/internals.md) 104 | 105 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that. 106 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community). 107 | 108 | ## License 109 | 110 | The Yii Rate Limiter Middleware is free software. It is released under the terms of the BSD License. 111 | Please see [`LICENSE`](./LICENSE.md) for more information. 112 | 113 | Maintained by [Yii Software](https://www.yiiframework.com/). 114 | 115 | ## Support the project 116 | 117 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 118 | 119 | ## Follow updates 120 | 121 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 122 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 123 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 124 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 125 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](https://yiiframework.com/go/slack) 126 | -------------------------------------------------------------------------------- /src/Counter.php: -------------------------------------------------------------------------------- 1 | periodInMilliseconds = $periodInSeconds * self::MILLISECONDS_PER_SECOND; 64 | $this->timer = $timer ?: new MicrotimeTimer(); 65 | $this->incrementIntervalInMilliseconds = $this->periodInMilliseconds / $this->limit; 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | */ 71 | public function hit(string $id): CounterState 72 | { 73 | $attempts = 0; 74 | $isFailStoreUpdatedData = false; 75 | do { 76 | // Last increment time. 77 | // In GCRA it's known as arrival time. 78 | $lastIncrementTimeInMilliseconds = $this->timer->nowInMilliseconds(); 79 | 80 | $lastStoredTheoreticalNextIncrementTime = $this->getLastStoredTheoreticalNextIncrementTime($id); 81 | 82 | $theoreticalNextIncrementTime = $this->calculateTheoreticalNextIncrementTime( 83 | $lastIncrementTimeInMilliseconds, 84 | $lastStoredTheoreticalNextIncrementTime 85 | ); 86 | 87 | $remaining = $this->calculateRemaining($lastIncrementTimeInMilliseconds, $theoreticalNextIncrementTime); 88 | $resetAfter = $this->calculateResetAfter($theoreticalNextIncrementTime); 89 | 90 | if ($remaining === 0) { 91 | break; 92 | } 93 | 94 | $isStored = $this->storeTheoreticalNextIncrementTime( 95 | $id, 96 | $theoreticalNextIncrementTime, 97 | $lastStoredTheoreticalNextIncrementTime 98 | ); 99 | if ($isStored) { 100 | break; 101 | } 102 | 103 | $attempts++; 104 | if ($attempts >= $this->maxCasAttempts) { 105 | $isFailStoreUpdatedData = true; 106 | break; 107 | } 108 | } while (true); 109 | 110 | return new CounterState($this->limit, $remaining, $resetAfter, $isFailStoreUpdatedData); 111 | } 112 | 113 | /** 114 | * @return float Theoretical increment time that would be expected from equally spaced increments at exactly rate 115 | * limit. In GCRA it is known as TAT, theoretical arrival time. 116 | */ 117 | private function calculateTheoreticalNextIncrementTime( 118 | float $lastIncrementTimeInMilliseconds, 119 | ?float $storedTheoreticalNextIncrementTime 120 | ): float { 121 | return ( 122 | $storedTheoreticalNextIncrementTime === null 123 | ? $lastIncrementTimeInMilliseconds 124 | : max($lastIncrementTimeInMilliseconds, $storedTheoreticalNextIncrementTime) 125 | ) + $this->incrementIntervalInMilliseconds; 126 | } 127 | 128 | /** 129 | * @return int The number of remaining requests in the current time period. 130 | */ 131 | private function calculateRemaining( 132 | float $lastIncrementTimeInMilliseconds, 133 | float $theoreticalNextIncrementTime 134 | ): int { 135 | $incrementAllowedAt = $theoreticalNextIncrementTime - $this->periodInMilliseconds; 136 | 137 | $remainingTimeInMilliseconds = round($lastIncrementTimeInMilliseconds - $incrementAllowedAt); 138 | if ($remainingTimeInMilliseconds > 0) { 139 | return (int) ($remainingTimeInMilliseconds / $this->incrementIntervalInMilliseconds); 140 | } 141 | 142 | return 0; 143 | } 144 | 145 | private function getLastStoredTheoreticalNextIncrementTime(string $id): ?float 146 | { 147 | return $this->storage->get($this->getStorageKey($id)); 148 | } 149 | 150 | private function storeTheoreticalNextIncrementTime( 151 | string $id, 152 | float $theoreticalNextIncrementTime, 153 | ?float $lastStoredTheoreticalNextIncrementTime 154 | ): bool { 155 | if ($lastStoredTheoreticalNextIncrementTime !== null) { 156 | return $this->storage->saveCompareAndSwap( 157 | $this->getStorageKey($id), 158 | $lastStoredTheoreticalNextIncrementTime, 159 | $theoreticalNextIncrementTime, 160 | $this->storageTtlInSeconds 161 | ); 162 | } 163 | 164 | return $this->storage->saveIfNotExists( 165 | $this->getStorageKey($id), 166 | $theoreticalNextIncrementTime, 167 | $this->storageTtlInSeconds 168 | ); 169 | } 170 | 171 | /** 172 | * @return int Timestamp to wait until the rate limit resets. 173 | */ 174 | private function calculateResetAfter(float $theoreticalNextIncrementTime): int 175 | { 176 | return (int) ($theoreticalNextIncrementTime / self::MILLISECONDS_PER_SECOND); 177 | } 178 | 179 | /** 180 | * @return string Storage key used to store the next increment time. 181 | */ 182 | private function getStorageKey(string $id): string 183 | { 184 | return $this->storagePrefix . $id; 185 | } 186 | } 187 | --------------------------------------------------------------------------------