Commit 12cd71d7 by Qiang Xue

Refactored rate limiting by turning RateLimiter into an action filter.

parent 3616d1f0
...@@ -689,7 +689,7 @@ To prevent abuse, you should consider adding rate limiting to your APIs. For exa ...@@ -689,7 +689,7 @@ To prevent abuse, you should consider adding rate limiting to your APIs. For exa
of each user to be at most 100 API calls within a period of 10 minutes. If too many requests are received from a user of each user to be at most 100 API calls within a period of 10 minutes. If too many requests are received from a user
within the period of the time, a response with status code 429 (meaning Too Many Requests) should be returned. within the period of the time, a response with status code 429 (meaning Too Many Requests) should be returned.
To enable rate limiting, the [[yii\web\User::identityClass|user identity class]] should implement [[yii\rest\RateLimitInterface]]. To enable rate limiting, the [[yii\web\User::identityClass|user identity class]] should implement [[yii\filters\RateLimitInterface]].
This interface requires implementation of the following three methods: This interface requires implementation of the following three methods:
* `getRateLimit()`: returns the maximum number of allowed requests and the time period, e.g., `[100, 600]` means * `getRateLimit()`: returns the maximum number of allowed requests and the time period, e.g., `[100, 600]` means
...@@ -703,17 +703,33 @@ And `loadAllowance()` and `saveAllowance()` can then be implementation by readin ...@@ -703,17 +703,33 @@ And `loadAllowance()` and `saveAllowance()` can then be implementation by readin
of the two columns corresponding to the current authenticated user. To improve performance, you may also of the two columns corresponding to the current authenticated user. To improve performance, you may also
consider storing these information in cache or some NoSQL storage. consider storing these information in cache or some NoSQL storage.
Once the identity class implements the required interface, Yii will automatically use the rate limiter Once the identity class implements the required interface, Yii will automatically use [[yii\filters\RateLimiter]]
as specified by [[yii\rest\Controller::rateLimiter]] to perform rate limiting check. The rate limiter configured as an action filter for [[yii\rest\Controller]] to perform rate limiting check. The rate limiter
will thrown a [[yii\web\TooManyRequestsHttpException]] if rate limit is exceeded. will thrown a [[yii\web\TooManyRequestsHttpException]] if rate limit is exceeded. You may configure the rate limiter
as follows in your REST controller classes,
When rate limiting is enabled, every response will be sent with the following HTTP headers containing ```php
public function behaviors()
{
return array_merge(parent::behaviors(), [
'rateLimiter' => [
'class' => \yii\filters\RateLimiter::className(),
'enableRateLimitHeaders' => false,
],
]);
}
```
When rate limiting is enabled, by default every response will be sent with the following HTTP headers containing
the current rate limiting information: the current rate limiting information:
* `X-Rate-Limit-Limit`: The maximum number of requests allowed with a time period; * `X-Rate-Limit-Limit`: The maximum number of requests allowed with a time period;
* `X-Rate-Limit-Remaining`: The number of remaining requests in the current time period; * `X-Rate-Limit-Remaining`: The number of remaining requests in the current time period;
* `X-Rate-Limit-Reset`: The number of seconds to wait in order to get the maximum number of allowed requests. * `X-Rate-Limit-Reset`: The number of seconds to wait in order to get the maximum number of allowed requests.
You may disable these headers by configuring [[yii\filters\RateLimiter::enableRateLimitHeaders]] to be false,
like shown in the above code example.
Error Handling Error Handling
-------------- --------------
......
...@@ -289,6 +289,7 @@ Yii Framework 2 Change Log ...@@ -289,6 +289,7 @@ Yii Framework 2 Change Log
- New: Yii framework now comes with core messages in multiple languages - New: Yii framework now comes with core messages in multiple languages
- New: Added `yii\codeception\DbTestCase` (qiangxue) - New: Added `yii\codeception\DbTestCase` (qiangxue)
- New: Added `yii\web\GroupUrlRule` (qiangxue) - New: Added `yii\web\GroupUrlRule` (qiangxue)
- New: Added `yii\filters\RateLimiter` (qiangxue)
2.0.0-alpha, December 1, 2013 2.0.0-alpha, December 1, 2013
----------------------------- -----------------------------
......
...@@ -194,8 +194,8 @@ return [ ...@@ -194,8 +194,8 @@ return [
'yii\rest\IndexAction' => YII_PATH . '/rest/IndexAction.php', 'yii\rest\IndexAction' => YII_PATH . '/rest/IndexAction.php',
'yii\rest\OptionsAction' => YII_PATH . '/rest/OptionsAction.php', 'yii\rest\OptionsAction' => YII_PATH . '/rest/OptionsAction.php',
'yii\rest\QueryParamAuth' => YII_PATH . '/rest/QueryParamAuth.php', 'yii\rest\QueryParamAuth' => YII_PATH . '/rest/QueryParamAuth.php',
'yii\rest\RateLimitInterface' => YII_PATH . '/rest/RateLimitInterface.php', 'yii\filters\RateLimitInterface' => YII_PATH . '/filters/RateLimitInterface.php',
'yii\rest\RateLimiter' => YII_PATH . '/rest/RateLimiter.php', 'yii\filters\RateLimiter' => YII_PATH . '/filters/RateLimiter.php',
'yii\rest\Serializer' => YII_PATH . '/rest/Serializer.php', 'yii\rest\Serializer' => YII_PATH . '/rest/Serializer.php',
'yii\rest\UpdateAction' => YII_PATH . '/rest/UpdateAction.php', 'yii\rest\UpdateAction' => YII_PATH . '/rest/UpdateAction.php',
'yii\rest\UrlRule' => YII_PATH . '/rest/UrlRule.php', 'yii\rest\UrlRule' => YII_PATH . '/rest/UrlRule.php',
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
* @license http://www.yiiframework.com/license/ * @license http://www.yiiframework.com/license/
*/ */
namespace yii\rest; namespace yii\filters;
/** /**
* RateLimitInterface is the interface that may be implemented by an identity object to enforce rate limiting. * RateLimitInterface is the interface that may be implemented by an identity object to enforce rate limiting.
...@@ -17,23 +17,26 @@ interface RateLimitInterface ...@@ -17,23 +17,26 @@ interface RateLimitInterface
{ {
/** /**
* Returns the maximum number of allowed requests and the window size. * Returns the maximum number of allowed requests and the window size.
* @param array $params the additional parameters associated with the rate limit. * @param \yii\web\Request $request the current request
* @param \yii\base\Action $action the action to be executed
* @return array an array of two elements. The first element is the maximum number of allowed requests, * @return array an array of two elements. The first element is the maximum number of allowed requests,
* and the second element is the size of the window in seconds. * and the second element is the size of the window in seconds.
*/ */
public function getRateLimit($params = []); public function getRateLimit($request, $action);
/** /**
* Loads the number of allowed requests and the corresponding timestamp from a persistent storage. * Loads the number of allowed requests and the corresponding timestamp from a persistent storage.
* @param array $params the additional parameters associated with the rate limit. * @param \yii\web\Request $request the current request
* @param \yii\base\Action $action the action to be executed
* @return array an array of two elements. The first element is the number of allowed requests, * @return array an array of two elements. The first element is the number of allowed requests,
* and the second element is the corresponding UNIX timestamp. * and the second element is the corresponding UNIX timestamp.
*/ */
public function loadAllowance($params = []); public function loadAllowance($request, $action);
/** /**
* Saves the number of allowed requests and the corresponding timestamp to a persistent storage. * Saves the number of allowed requests and the corresponding timestamp to a persistent storage.
* @param \yii\web\Request $request the current request
* @param \yii\base\Action $action the action to be executed
* @param integer $allowance the number of allowed requests remaining. * @param integer $allowance the number of allowed requests remaining.
* @param integer $timestamp the current timestamp. * @param integer $timestamp the current timestamp.
* @param array $params the additional parameters associated with the rate limit.
*/ */
public function saveAllowance($allowance, $timestamp, $params = []); public function saveAllowance($request, $action, $allowance, $timestamp);
} }
...@@ -5,9 +5,10 @@ ...@@ -5,9 +5,10 @@
* @license http://www.yiiframework.com/license/ * @license http://www.yiiframework.com/license/
*/ */
namespace yii\rest; namespace yii\filters;
use yii\base\Component; use Yii;
use yii\base\ActionFilter;
use yii\web\Request; use yii\web\Request;
use yii\web\Response; use yii\web\Response;
use yii\web\TooManyRequestsHttpException; use yii\web\TooManyRequestsHttpException;
...@@ -15,14 +16,35 @@ use yii\web\TooManyRequestsHttpException; ...@@ -15,14 +16,35 @@ use yii\web\TooManyRequestsHttpException;
/** /**
* RateLimiter implements a rate limiting algorithm based on the [leaky bucket algorithm](http://en.wikipedia.org/wiki/Leaky_bucket). * RateLimiter implements a rate limiting algorithm based on the [leaky bucket algorithm](http://en.wikipedia.org/wiki/Leaky_bucket).
* *
* You may call [[check()]] to enforce rate limiting. * You may use RateLimiter by attaching it as a behavior to a controller or module, like the following,
*
* ```php
* public function behaviors()
* {
* return [
* 'rateLimiter' => [
* 'class' => \yii\filters\RateLimiter::className(),
* ],
* ];
* }
* ```
*
* When the user has exceeded his rate limit, RateLimiter will throw a [[TooManyRequestsHttpException]] exception.
*
* Note that RateLimiter requires [[user]] to implement the [[RateLimitInterface]]. RateLimiter will
* do nothing if [[user]] is not set or does not implement [[RateLimitInterface]].
* *
* @author Qiang Xue <qiang.xue@gmail.com> * @author Qiang Xue <qiang.xue@gmail.com>
* @since 2.0 * @since 2.0
*/ */
class RateLimiter extends Component class RateLimiter extends ActionFilter
{ {
/** /**
* @var RateLimitInterface the user object that implements the RateLimitInterface.
* If not set, it will take the value of `Yii::$app->user->getIdentity(false)`.
*/
public $user;
/**
* @var boolean whether to include rate limit headers in the response * @var boolean whether to include rate limit headers in the response
*/ */
public $enableRateLimitHeaders = true; public $enableRateLimitHeaders = true;
...@@ -31,6 +53,24 @@ class RateLimiter extends Component ...@@ -31,6 +53,24 @@ class RateLimiter extends Component
*/ */
public $errorMessage = 'Rate limit exceeded.'; public $errorMessage = 'Rate limit exceeded.';
/**
* @inheritdoc
*/
public function beforeAction($action)
{
$user = $this->user ? : Yii::$app->getUser()->getIdentity(false);
if ($user instanceof RateLimitInterface) {
Yii::trace('Check rate limit', __METHOD__);
$this->checkRateLimit($user, Yii::$app->getRequest(), Yii::$app->getResponse(), $action);
} elseif ($user) {
Yii::info('Rate limit skipped: "user" does not implement RateLimitInterface.');
} else {
Yii::info('Rate limit skipped: user not logged in.');
}
return true;
}
/** /**
* Checks whether the rate limit exceeds. * Checks whether the rate limit exceeds.
* @param RateLimitInterface $user the current user * @param RateLimitInterface $user the current user
...@@ -39,16 +79,12 @@ class RateLimiter extends Component ...@@ -39,16 +79,12 @@ class RateLimiter extends Component
* @param \yii\base\Action $action the action to be executed * @param \yii\base\Action $action the action to be executed
* @throws TooManyRequestsHttpException if rate limit exceeds * @throws TooManyRequestsHttpException if rate limit exceeds
*/ */
public function check($user, $request, $response, $action) public function checkRateLimit($user, $request, $response, $action)
{ {
$current = time(); $current = time();
$params = [
'request' => $request,
'action' => $action,
];
list ($limit, $window) = $user->getRateLimit($params); list ($limit, $window) = $user->getRateLimit($request, $action);
list ($allowance, $timestamp) = $user->loadAllowance($params); list ($allowance, $timestamp) = $user->loadAllowance($request, $action);
$allowance += (int) (($current - $timestamp) * $limit / $window); $allowance += (int) (($current - $timestamp) * $limit / $window);
if ($allowance > $limit) { if ($allowance > $limit) {
...@@ -56,11 +92,11 @@ class RateLimiter extends Component ...@@ -56,11 +92,11 @@ class RateLimiter extends Component
} }
if ($allowance < 1) { if ($allowance < 1) {
$user->saveAllowance(0, $current, $params); $user->saveAllowance($request, $action, 0, $current);
$this->addRateLimitHeaders($response, $limit, 0, $window); $this->addRateLimitHeaders($response, $limit, 0, $window);
throw new TooManyRequestsHttpException($this->errorMessage); throw new TooManyRequestsHttpException($this->errorMessage);
} else { } else {
$user->saveAllowance($allowance - 1, $current, $params); $user->saveAllowance($request, $action, $allowance - 1, $current);
$this->addRateLimitHeaders($response, $limit, 0, (int) (($limit - $allowance) * $window / $limit)); $this->addRateLimitHeaders($response, $limit, 0, (int) (($limit - $allowance) * $window / $limit));
} }
} }
...@@ -72,7 +108,7 @@ class RateLimiter extends Component ...@@ -72,7 +108,7 @@ class RateLimiter extends Component
* @param integer $remaining the remaining number of allowed requests within the current period * @param integer $remaining the remaining number of allowed requests within the current period
* @param integer $reset the number of seconds to wait before having maximum number of allowed requests again * @param integer $reset the number of seconds to wait before having maximum number of allowed requests again
*/ */
protected function addRateLimitHeaders($response, $limit, $remaining, $reset) public function addRateLimitHeaders($response, $limit, $remaining, $reset)
{ {
if ($this->enableRateLimitHeaders) { if ($this->enableRateLimitHeaders) {
$response->getHeaders() $response->getHeaders()
......
...@@ -9,10 +9,10 @@ namespace yii\rest; ...@@ -9,10 +9,10 @@ namespace yii\rest;
use Yii; use Yii;
use yii\base\InvalidConfigException; use yii\base\InvalidConfigException;
use yii\filters\RateLimiter;
use yii\web\Response; use yii\web\Response;
use yii\web\UnauthorizedHttpException; use yii\web\UnauthorizedHttpException;
use yii\web\UnsupportedMediaTypeHttpException; use yii\web\UnsupportedMediaTypeHttpException;
use yii\web\TooManyRequestsHttpException;
use yii\filters\VerbFilter; use yii\filters\VerbFilter;
use yii\web\ForbiddenHttpException; use yii\web\ForbiddenHttpException;
...@@ -50,14 +50,6 @@ class Controller extends \yii\web\Controller ...@@ -50,14 +50,6 @@ class Controller extends \yii\web\Controller
*/ */
public $authMethods; public $authMethods;
/** /**
* @var string|array the rate limiter class or configuration. If this is not set or empty,
* the rate limiting will be disabled. Note that if the user is not authenticated, the rate limiting
* will also NOT be performed.
* @see checkRateLimit()
* @see authMethods
*/
public $rateLimiter = 'yii\rest\RateLimiter';
/**
* @var string the chosen API version number, or null if [[supportedVersions]] is empty. * @var string the chosen API version number, or null if [[supportedVersions]] is empty.
* @see supportedVersions * @see supportedVersions
*/ */
...@@ -88,6 +80,9 @@ class Controller extends \yii\web\Controller ...@@ -88,6 +80,9 @@ class Controller extends \yii\web\Controller
'class' => VerbFilter::className(), 'class' => VerbFilter::className(),
'actions' => $this->verbs(), 'actions' => $this->verbs(),
], ],
'rateLimiter' => [
'class' => RateLimiter::className(),
],
]; ];
} }
...@@ -106,7 +101,6 @@ class Controller extends \yii\web\Controller ...@@ -106,7 +101,6 @@ class Controller extends \yii\web\Controller
public function beforeAction($action) public function beforeAction($action)
{ {
$this->authenticate($action); $this->authenticate($action);
$this->checkRateLimit($action);
return parent::beforeAction($action); return parent::beforeAction($action);
} }
...@@ -193,30 +187,6 @@ class Controller extends \yii\web\Controller ...@@ -193,30 +187,6 @@ class Controller extends \yii\web\Controller
} }
/** /**
* Ensures the rate limit is not exceeded.
*
* This method will use [[rateLimiter]] to check rate limit. In order to perform rate limiting check,
* the user must be authenticated and the user identity object (`Yii::$app->user->identity`) must
* implement [[RateLimitInterface]].
*
* @param \yii\base\Action $action the action to be executed
* @throws TooManyRequestsHttpException if the rate limit is exceeded.
*/
protected function checkRateLimit($action)
{
if (empty($this->rateLimiter)) {
return;
}
$identity = Yii::$app->getUser()->getIdentity(false);
if ($identity instanceof RateLimitInterface) {
/** @var RateLimiter $rateLimiter */
$rateLimiter = Yii::createObject($this->rateLimiter);
$rateLimiter->check($identity, Yii::$app->getRequest(), Yii::$app->getResponse(), $action);
}
}
/**
* Serializes the specified data. * Serializes the specified data.
* The default implementation will create a serializer based on the configuration given by [[serializer]]. * The default implementation will create a serializer based on the configuration given by [[serializer]].
* It then uses the serializer to serialize the given data. * It then uses the serializer to serialize the given data.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment