mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
NEW RateLimiter for Security controller
This commit is contained in:
parent
7b3286d512
commit
04b1bb816e
@ -18,3 +18,7 @@ SilverStripe\Core\Injector\Injector:
|
||||
factory: SilverStripe\Core\Cache\CacheFactory
|
||||
constructor:
|
||||
namespace: "VersionProvider_composerlock"
|
||||
Psr\SimpleCache\CacheInterface.RateLimiter:
|
||||
factory: SilverStripe\Core\Cache\CacheFactory
|
||||
constructor:
|
||||
namespace: 'ratelimiter'
|
||||
|
@ -17,6 +17,12 @@ SilverStripe\Core\Injector\Injector:
|
||||
SilverStripe\Control\Middleware\TrustedProxyMiddleware:
|
||||
properties:
|
||||
TrustedProxyIPs: '`SS_TRUSTED_PROXY_IPS`'
|
||||
RateLimitedSecurityController:
|
||||
class: SilverStripe\Control\Middleware\RequestHandlerMiddlewareAdapter
|
||||
properties:
|
||||
RequestHandler: '%$SilverStripe\Security\Security'
|
||||
Middlewares:
|
||||
- '%$SilverStripe\Control\Middleware\RateLimitMiddleware'
|
||||
---
|
||||
Name: errorrequestprocessors
|
||||
After:
|
||||
|
@ -11,7 +11,7 @@ After:
|
||||
---
|
||||
SilverStripe\Control\Director:
|
||||
rules:
|
||||
'Security//$Action/$ID/$OtherID': SilverStripe\Security\Security
|
||||
'Security//$Action/$ID/$OtherID': '%$RateLimitedSecurityController'
|
||||
'CMSSecurity//$Action/$ID/$OtherID': SilverStripe\Security\CMSSecurity
|
||||
'dev': SilverStripe\Dev\DevelopmentAdmin
|
||||
'interactive': SilverStripe\Dev\SapphireREPL
|
||||
|
72
src/Control/Middleware/RateLimitMiddleware.php
Normal file
72
src/Control/Middleware/RateLimitMiddleware.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Control\Middleware;
|
||||
|
||||
use SilverStripe\Control\HTTPRequest;
|
||||
use SilverStripe\Control\HTTPResponse;
|
||||
use SilverStripe\Core\Cache\RateLimiter;
|
||||
use SilverStripe\ErrorPage\ErrorPage;
|
||||
use SilverStripe\ORM\FieldType\DBDatetime;
|
||||
use SilverStripe\Security\Security;
|
||||
|
||||
class RateLimitMiddleware implements HTTPMiddleware
|
||||
{
|
||||
|
||||
/**
|
||||
* @param HTTPRequest $request
|
||||
* @param callable $delegate
|
||||
* @return HTTPResponse
|
||||
*/
|
||||
public function process(HTTPRequest $request, callable $delegate)
|
||||
{
|
||||
$limiter = new RateLimiter($this->getKeyFromRequest($request), 10, 1);
|
||||
if ($limiter->canAccess()) {
|
||||
$limiter->hit();
|
||||
$response = $delegate($request);
|
||||
} else {
|
||||
$response = $this->getErrorHTTPResponse();
|
||||
}
|
||||
$this->addHeadersToResponse($response, $limiter);
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param HTTPRequest $request
|
||||
* @return string
|
||||
*/
|
||||
protected function getKeyFromRequest($request)
|
||||
{
|
||||
$domain = parse_url($request->getURL(), PHP_URL_HOST);
|
||||
if ($currentUser = Security::getCurrentUser()) {
|
||||
return md5($domain . '-' . $currentUser->ID);
|
||||
}
|
||||
return md5($domain . '-' . $request->getIP());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HTTPResponse
|
||||
*/
|
||||
protected function getErrorHTTPResponse()
|
||||
{
|
||||
$response = null;
|
||||
if (class_exists(ErrorPage::class)) {
|
||||
$response = ErrorPage::response_for(429);
|
||||
}
|
||||
return $response ?: new HTTPResponse('<h1>429 - Too many requests</h1>', 429);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param HTTPResponse $response
|
||||
* @param RateLimiter $limiter
|
||||
*/
|
||||
protected function addHeadersToResponse($response, $limiter)
|
||||
{
|
||||
$response->addHeader('X-RateLimit-Limit', $limiter->getMaxAttempts());
|
||||
$response->addHeader('X-RateLimit-Remaining', $remaining = $limiter->getNumAttemptsRemaining());
|
||||
$ttl = $limiter->getTimeToReset();
|
||||
$response->addHeader('X-RateLimit-Reset', DBDatetime::now()->getTimestamp() + $ttl);
|
||||
if ($remaining <= 0) {
|
||||
$response->addHeader('Retry-After', $ttl);
|
||||
}
|
||||
}
|
||||
}
|
190
src/Core/Cache/RateLimiter.php
Normal file
190
src/Core/Cache/RateLimiter.php
Normal file
@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Core\Cache;
|
||||
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use SilverStripe\Core\Injector\Injector;
|
||||
use SilverStripe\ORM\FieldType\DBDatetime;
|
||||
|
||||
class RateLimiter
|
||||
{
|
||||
/**
|
||||
* @var CacheInterface
|
||||
*/
|
||||
private $cache;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $identifier;
|
||||
|
||||
/**
|
||||
* @var int Maximum number of attempts allowed
|
||||
*/
|
||||
private $maxAttempts;
|
||||
|
||||
/**
|
||||
* @var int How long the rate limit lasts for
|
||||
*/
|
||||
private $decay;
|
||||
|
||||
/**
|
||||
* RateLimiter constructor.
|
||||
* @param string $identifier
|
||||
* @param int $maxAttempts
|
||||
* @param int $decay
|
||||
*/
|
||||
public function __construct($identifier, $maxAttempts, $decay)
|
||||
{
|
||||
$this->setIdentifier($identifier);
|
||||
$this->setMaxAttempts($maxAttempts);
|
||||
$this->setDecay($decay);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return CacheInterface
|
||||
*/
|
||||
public function getCache()
|
||||
{
|
||||
if (!$this->cache) {
|
||||
$this->setCache(Injector::inst()->create(CacheInterface::class . '.RateLimiter'));
|
||||
}
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param CacheInterface $cache
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setCache($cache)
|
||||
{
|
||||
$this->cache = $cache;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getIdentifier()
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $identifier
|
||||
* @return $this
|
||||
*/
|
||||
public function setIdentifier($identifier)
|
||||
{
|
||||
$this->identifier = $identifier;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getMaxAttempts()
|
||||
{
|
||||
return $this->maxAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $maxAttempts
|
||||
* @return $this
|
||||
*/
|
||||
public function setMaxAttempts($maxAttempts)
|
||||
{
|
||||
$this->maxAttempts = $maxAttempts;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getDecay()
|
||||
{
|
||||
return $this->decay;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $decay
|
||||
* @return $this
|
||||
*/
|
||||
public function setDecay($decay)
|
||||
{
|
||||
$this->decay = $decay;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getNumAttempts()
|
||||
{
|
||||
return $this->getCache()->get($this->getIdentifier(), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getNumAttemptsRemaining()
|
||||
{
|
||||
return max(0, $this->getMaxAttempts() - $this->getNumAttempts());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getTimeToReset()
|
||||
{
|
||||
if ($expiry = $this->getCache()->get($this->getIdentifier() . '-timer')) {
|
||||
return $expiry - DBDatetime::now()->getTimestamp();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function clearAttempts()
|
||||
{
|
||||
$this->getCache()->delete($this->getIdentifier());
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a hit in the rate limit cache
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function hit()
|
||||
{
|
||||
if (!$this->getCache()->has($this->getIdentifier())) {
|
||||
$ttl = $this->getDecay() * 60;
|
||||
$expiry = DBDatetime::now()->getTimestamp() + $ttl;
|
||||
$this->getCache()->set($this->getIdentifier() . '-timer', $expiry, $ttl);
|
||||
} else {
|
||||
$expiry = $this->getCache()->get($this->getIdentifier() . '-timer');
|
||||
$ttl = $expiry - DBDatetime::now()->getTimestamp();
|
||||
}
|
||||
$this->getCache()->set($this->getIdentifier(), $this->getNumAttempts() + 1, $ttl);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function canAccess()
|
||||
{
|
||||
if ($this->getNumAttempts() >= $this->getMaxAttempts()) {
|
||||
// if the timer cache item still exists then they are locked out
|
||||
if ($this->getCache()->has($this->getIdentifier() . '-timer')) {
|
||||
return false;
|
||||
}
|
||||
// the timer key has expired so we can clear their attempts and start again
|
||||
$this->clearAttempts();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
@ -1090,7 +1090,7 @@ class SapphireTest extends PHPUnit_Framework_TestCase implements TestOnly
|
||||
*/
|
||||
public static function getExtraControllers()
|
||||
{
|
||||
return static::$extra_controllers;
|
||||
return array_merge([Security::class], static::$extra_controllers);
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user