Skip to content

Commit

Permalink
Adding keys and ips options. Refatoring and renaming the middleware. …
Browse files Browse the repository at this point in the history
…Added changelog
  • Loading branch information
Leandro Silva committed May 8, 2018
1 parent 1579773 commit 776002d
Show file tree
Hide file tree
Showing 15 changed files with 140 additions and 135 deletions.
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Changelog

All notable changes to this project will be documented in this file, in reverse chronological order by release.

## 2.1.0 - 2018-05-08

### Added

- `keys` Specify different max_requests/reset_time per api key.
- `ips` Specify different max_requests/reset_time per IP.

### Changed

- Renamed RateLimit to RateLimitMiddleware.
- Changing visibility of `options` property to allow extends

### Deprecated

- Nothing.

### Removed

- `RateLimitResponseFactory` and generating using `zend-problem-detail`.

### Fixed

- Nothing.
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Attention! This middleware does not validate the Api Key, you must add a middlew
* PHP >= 7.1
* Psr\HttpMessage

This middleware uses one of the pre-implemented storages:
This middleware uses one of the pre-implemented storages:
* Apc (default)
* Array
* Aura Session
Expand Down Expand Up @@ -49,6 +49,23 @@ php composer.phar require los/los-rate-limit
'X-Forwarded-For',
],
'forwarded_ip_index' => null,
'headers' => [
'limit' => 'X-RateLimit-Limit',
'remaining' => 'X-RateLimit-Remaining',
'reset' => 'X-RateLimit-Reset',
],
'keys' => [
'b9155515728fa0f69d9770f7877cb50a' => [
'max_requests' => 100,
'reset_time' => 3600,
],
],
'ips' => [
'127.0.0.1' => [
'max_requests' => 100,
'reset_time' => 3600,
],
],
]
```

Expand All @@ -61,17 +78,19 @@ php composer.phar require los/los-rate-limit
* `prefer_forwarded` Whether forwarded headers should be used in preference to the remote address, e.g. if all requests are forwarded through a routing component or reverse proxy which adds these headers predictably. This is a bad idea unless your app can **only** be reached this way.
* `forwarded_headers_allowed` An array of strings which are headers you trust to contain source IP addresses.
* `forwarded_ip_index` If null (default), the first plausible IP in an XFF header (reading left to right) is used. If numeric, only a specific index of IP is used. Use `-2` to get the penultimate IP from the list, which could make sense if the header always ends `...<client_ip>, <router_ip>`. Or use `0` to use only the first IP (stopping if it's not valid). Like `prefer_forwarded`, this only makes sense if your app's always reached through a predictable hop that controls the header - remember these are easily spoofed on the initial request.
* `keys` Specify different max_requests/reset_time per api key
* `ips` Specify different max_requests/reset_time per IP

The values above indicate that the user can trigger 100 requests per hour.

If you want to disable ip access (e.g. allowing just access via X-Api-Key), just set ip_max_requests to 0 (zero).

## Usage

Just add the middleware as one of the first in your application.
Just add the middleware as one of the first in your application.

### Zend Expressive

If you are using [expressive-skeleton](https://github.com/zendframework/zend-expressive-skeleton),
you can copy `config/los-rate-limit.local.php.dist` to
If you are using [expressive-skeleton](https://github.com/zendframework/zend-expressive-skeleton),
you can copy `config/los-rate-limit.local.php.dist` to
`config/autoload/los-rate-limit.local.php` and modify configuration as your needs.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"squizlabs/php_codesniffer": "^2.7",
"phpstan/phpstan": "^0.9.2",
"aura/session": "^2.1",
"zendframework/zend-session": "^2.8"
"zendframework/zend-session": "^2.8",
"zendframework/zend-servicemanager": "^3.3"
},
"license" : "BSD-3-Clause",
"keywords" : [ "api", "rate", "limit", "middleware" ],
Expand Down
9 changes: 8 additions & 1 deletion config/los-rate-limit.local.php.dist
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php

return [
'los_rate_limit' => [
'max_requests' => 100,
Expand All @@ -7,10 +8,16 @@ return [
'ip_reset_time' => 3600,
'api_header' => 'X-Api-Key',
'trust_forwarded' => false,
'headers' => [
'limit' => 'X-RateLimit-Limit',
'remaining' => 'X-RateLimit-Remaining',
'reset' => 'X-RateLimit-Reset',
],
],
'dependencies' => [
'factories' => [
LosMiddleware\RateLimit\RateLimit::class => LosMiddleware\RateLimit\RateLimitFactory::class,
LosMiddleware\RateLimit\RateLimitMiddleware::class =>
LosMiddleware\RateLimit\RateLimitMiddlewareFactory::class,
],
],
];
18 changes: 17 additions & 1 deletion src/Exception/RateLimitException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

namespace LosMiddleware\RateLimit\Exception;

class RateLimitException extends \Exception
use Exception;
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;

class RateLimitException extends Exception implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;

public static function create($maxRequests)
{
$message = sprintf('You have exceeded your %d requests rate limit', $maxRequests);
$e = new self($message);
$e->status = 429;
$e->detail = $message;
$e->type = 'https://httpstatuses.com/429';
$e->title = 'Too Many Requests';
return $e;
}
}
75 changes: 38 additions & 37 deletions src/RateLimit.php → src/RateLimitMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,44 @@

namespace LosMiddleware\RateLimit;

use LosMiddleware\RateLimit\Exception\RateLimitException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use LosMiddleware\RateLimit\Storage\StorageInterface;
use LosMiddleware\RateLimit\Exception\MissingParameterException;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response;
use Zend\ProblemDetails\ProblemDetailsResponseFactory;

class RateLimit implements MiddlewareInterface
class RateLimitMiddleware implements MiddlewareInterface
{
const HEADER_LIMIT = 'X-Rate-Limit-Limit';
const HEADER_RESET = 'X-Rate-Limit-Reset';
const HEADER_REMAINING = 'X-Rate-Limit-Remaining';
const HEADER_LIMIT = 'X-RateLimit-Limit';
const HEADER_RESET = 'X-RateLimit-Reset';
const HEADER_REMAINING = 'X-RateLimit-Remaining';

/**
* Storage class.
*
* @var \LosMiddleware\RateLimit\Storage\StorageInterface
*/
/** @var StorageInterface */
private $storage;

/**
* @var array
*/
private $options;
/** @var array */
protected $options;

/** @var ProblemDetailsResponseFactory */
private $problemDetailsResponseFactory;
private $problemResponseFactory;

/**
* Constructor.
*
* @param \LosMiddleware\RateLimit\Storage\StorageInterface $storage
* @param ProblemDetailsResponseFactory $problemDetailsResponseFactory
* @param ProblemDetailsResponseFactory $problemResponseFactory
* @param array $config
*/
public function __construct(
StorageInterface $storage,
ProblemDetailsResponseFactory $problemDetailsResponseFactory,
ProblemDetailsResponseFactory $problemResponseFactory,
$config = []
) {
$this->storage = $storage;
$this->problemDetailsResponseFactory = $problemDetailsResponseFactory;
$this->problemResponseFactory = $problemResponseFactory;
$this->options = array_replace([
'max_requests' => 100,
'reset_time' => 3600,
Expand All @@ -63,9 +57,16 @@ public function __construct(
'X-Forwarded-For',
],
'forwarded_ip_index' => null,
'headers' => [
'limit' => self::HEADER_LIMIT,
'remaining' => self::HEADER_REMAINING,
'reset' => self::HEADER_RESET,
],
'keys' => [],
'ips' => [],
], $config);

if ($this->options['prefer_forwarded'] && !$this->options['trust_forwarded']) {
if ($this->options['prefer_forwarded'] && ! $this->options['trust_forwarded']) {
throw new \LogicException('You must also "trust_forwarded" headers to "prefer_forwarded" ones.');
}
}
Expand All @@ -82,12 +83,12 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface

if (! empty($keyArray)) {
$key = $keyArray[0];
$maxRequests = $this->options['max_requests'];
$resetTime = $this->options['reset_time'];
$maxRequests = $this->options['keys'][$key]['max_requests'] ?? $this->options['max_requests'];
$resetTime = $this->options['keys'][$key]['reset_time'] ?? $this->options['reset_time'];
} else {
$key = $this->getClientIp($request);
$maxRequests = $this->options['ip_max_requests'];
$resetTime = $this->options['ip_reset_time'];
$maxRequests = $this->options['ips'][$key]['max_requests'] ?? $this->options['ip_max_requests'];
$resetTime = $this->options['ips'][$key]['reset_time'] ?? $this->options['ip_reset_time'];
}

if (empty($key)) {
Expand Down Expand Up @@ -131,17 +132,18 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
$this->storage->set($key, $data);

if ($remaining <= 0) {
$response = (new RateLimitResponseFactory(function () : ResponseInterface {
return new Response();
}))->create($request, (int) $maxRequests, $resetIn);

return $response;
$response = $this->problemResponseFactory->createResponseFromThrowable(
$request,
RateLimitException::create($maxRequests)
);
$response = $response->withAddedHeader($this->options['headers']['limit'], (string) $maxRequests);
return $response->withAddedHeader($this->options['headers']['reset'], (string) $resetIn);
}

$response = $handler->handle($request);
$response = $response->withHeader(self::HEADER_REMAINING, (string) $remaining);
$response = $response->withAddedHeader(self::HEADER_LIMIT, (string) $maxRequests);
$response = $response->withAddedHeader(self::HEADER_RESET, (string) $resetIn);
$response = $response->withHeader($this->options['headers']['remaining'], (string) $remaining);
$response = $response->withAddedHeader($this->options['headers']['limit'], (string) $maxRequests);
$response = $response->withAddedHeader($this->options['headers']['reset'], (string) $resetIn);

return $response;
}
Expand All @@ -154,15 +156,15 @@ private function getClientIp(ServerRequestInterface $request)
{
$server = $request->getServerParams();
$ips = [];
if (!empty($server['REMOTE_ADDR']) && $this->isIp($server['REMOTE_ADDR'])) {
if (! empty($server['REMOTE_ADDR']) && $this->isIp($server['REMOTE_ADDR'])) {
$ips[] = $server['REMOTE_ADDR'];
}

if ($this->options['trust_forwarded']) {
// At this point, we either couldn't find a real IP or prefer_forwarded ones.
foreach ($this->options['forwarded_headers_allowed'] as $name) {
$header = $request->getHeaderLine($name);
if (!empty($header)) {
if (! empty($header)) {
/** @var string[] $ips Possible IPs, verbatim from the forwarded header */
$ips = array_map('trim', explode(',', $header));

Expand All @@ -184,16 +186,15 @@ private function getClientIp(ServerRequestInterface $request)
}
}

if (isset($realIp)) {
// We waited in order to 'prefer_forwarded', but no acceptable forwarded option was found.
return $realIp;
if (count($ips) > 0) {
return $ips[0];
}

return null;
}

/**
* @param $possibleIp
* @param mixed $possibleIp
* @return bool
*/
private function isIp($possibleIp)
Expand Down
13 changes: 9 additions & 4 deletions src/RateLimitFactory.php → src/RateLimitMiddlewareFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

namespace LosMiddleware\RateLimit;

use LosMiddleware\RateLimit\Storage\ApcStorage;
use LosMiddleware\RateLimit\Storage\FileStorage;
use Psr\Container\ContainerInterface;
use Zend\ProblemDetails\ProblemDetailsResponseFactory;

class RateLimitFactory
class RateLimitMiddlewareFactory
{
/**
* @param ContainerInterface $container
* @return RateLimit
* @return RateLimitMiddleware
* @throws \Psr\Container\ContainerExceptionInterface
* @throws \Psr\Container\NotFoundExceptionInterface
*/
Expand All @@ -18,6 +19,10 @@ public function __invoke(ContainerInterface $container)
$config = $container->get('config');
$rateConfig = $config['los_rate_limit'] ?? [];

return new RateLimit(new ApcStorage(), $rateConfig);
return new RateLimitMiddleware(
new FileStorage(),
$container->get(ProblemDetailsResponseFactory::class),
$rateConfig
);
}
}
31 changes: 0 additions & 31 deletions src/RateLimitResponseFactory.php

This file was deleted.

Loading

0 comments on commit 776002d

Please sign in to comment.