
Laravel 8: ограничение трафика с помощью RateLimiter
12.04.2021 01:57 | Laravel
Ubuntu: 20.04
PHP: 8.0.3
Laravel: 8.36.2
В конце предыдущей статьи я упоминал RateLimiter, и потому хотел бы показать его применение на примере. Разумеется, вы можете найти подробную документацию по данной теме на сайте Laravel, однако я предлагаю пойти немного дальше.
Прежде всего, зачем нужно ограничивать доступ? Например, мы хотим:
- защитить логин от брутфорса
- защитить прочие формы от слишком частых отправок данных на сервер (обычные юзеры, естественно, не будут отправлять форму раз за разом, а вот злоумышленники - вполне могут делать подобное, причём программно)
- ограничить количество загрузок на сервер (особенно, актуально, если Вы позволяете пользователям загружать видео на свой сайт)
- ограничить отправку писем с сайта (например, на странице контактной формы)
- и т.д.
Итак, если в предыдущих версиях, мы использовали что-то вроде:
Route::middleware('auth:api', 'throttle:60,1')->group(function () {
Route::get('/user', function () {
//
});
});
то сейчас, открыв RouteServiceProvider
увидим:
<?php
...
class RouteServiceProvider extends ServiceProvider
{
...
public function boot()
{
$this->configureRateLimiting();
...
}
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
});
}
}
Соответственно, если предположить, что мы хотим создать отдельные лимиты для загрузки и отправки писем, да ещё и добавить кастомные ответы, можем добавить необходимый код в метод configureRateLimiting()
провайдера (здесь и далее имеется в виду app/Providers/RouteServiceProvider
):
use Illuminate\Http\Response;
...
protected function configureRateLimiting()
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(10)->by(optional($request->user())->id ?: $request->ip());
});
RateLimiter::for('uploads', function (Request $request) {
return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip())
->response(function () {
return response('Response for UPLOADS...', Response::HTTP_TOO_MANY_REQUESTS);
});
});
RateLimiter::for('emails', function (Request $request) {
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip())
->response(function () {
return response('Response for EMAILS...', Response::HTTP_TOO_MANY_REQUESTS);
});
});
}
и затем применить в файлах маршрутов:
Route::middleware(['throttle:uploads])->group(function () {
...
});
Route::middleware(['throttle:emails])->group(function () {
...
});
Это уже рабочий вариант, но что, если проект не маленький, и у нас, скажем 5 и более различных лимитов? Даже с дополнительными двумя выше, дублирование уже напрягает. Вот что мы сделаем:
- чтобы избежать дублирования, вынесем создание лимита в отдельный метод, в который будем передавать общие для всех ограничений параметры, а именно: название, максимальное количество попыток, кастомный респонс и HTTP-код ответа (вдруг по каким-то причинам нужно отдать не 429, а что-то другое)
- параметры лимитов вынесем в файл конфигурации. То бишь, если нам понадобится новый лимит, всего то надо будет дописать его настройки в файл конфига
- мы, конечно, можем обойтись и функией-хелпером
config()
, но в таком случае нам придётся работать с ассоциативным массивом и хардкодить ключи, что, лично мне, тоже не очень нравится. Поэтому создадим класс для настроек.
Начнём с конфигурации. В директории config
создаём файл throttle.php
и добавляем в него настройки:
<?php
use Illuminate\Http\Response;
return [
'api' => [
'max_attempts' => 100,
'response' => null,
'status' => null,
],
'uploads' => [
'max_attempts' => 3,
'response' => 'Custom response for FORMS',
'status' => Response::HTTP_TOO_MANY_REQUESTS,
],
'emails' => [
'max_attempts' => 5,
'response' => 'Custom response for EMAILS',
'status' => Response::HTTP_TOO_MANY_REQUESTS,
],
];
Далее займёмся классом. Так как класс преобразовывает массив в объект, или другими словами "приводит" массив к объекту, в директории app
создадим поддиректорию Casts
, и в ней класс ThrottleConfig
(т.е. полный путь к файлу app/Casts/ThrottleConfig.php
). Вставляем следующее содержимое:
<?php
namespace App\Casts;
class ThrottleConfig
{
private string $for;
private int $max_attempts;
private ?int $status;
private ?string $response;
public function __construct(string $for, array $params)
{
$this->for = $for;
collect($params)->each(fn ($value, $property) => $this->{$property} = $value);
}
public static function create(string $for, array $params): self
{
return new static($for, $params);
}
public function getFor(): string
{
return $this->for;
}
public function getMaxAttempts(): int
{
return $this->max_attempts;
}
public function getStatus(): int
{
return $this->status;
}
public function getResponse(): mixed
{
return $this->response;
}
public function hasResponse(): bool
{
return $this->response && $this->status;
}
}
Код довольно прост, и всё же, дам пояснения:
- конструктор принимает ключ (например, api, uploads, emails) и массив параметров, которые устанавливает в свойства класса
- по хорошему, надо добавить сеттеры - ведь мало ли кто чего напишет в конфиге, но ради простоты я опущу эту часть. Не думаю, что сеттеры вызовут трудности
- что до геттеров, вместо них можно было бы обойтись магическим методом
__get()
, при желании можете реализовать - статический метод create предназачен для создания инстанса
- посколько лимиты могут и не содержать кастомные респонсы, добавим метод, который проверяет, есть ли у нас ответ.
Возвращаемся в провайдер, и дописываем два приватных метода, первый забирает настройки из конфига:
private function limitingParameters(): array
{
return config('throttle');
}
...а второй - принимает экземпляр конфига и добавляет новый лимит. При этом помним, что в зависимости от параметров в config/throttle.php
в некоторых случаях пользовательской версии респонса может и не быть:
private function addLimit(ThrottleConfig $config): void
{
RateLimiter::for($config->getFor(), function (Request $request) use ($config) {
$limit = Limit::perMinute($config->getMaxAttempts())->by($request->user()?->id ?: $request->ip());
return $config->hasResponse()
? $limit->response(fn () => response($config->getResponse(), $config->getStatus()))
: $limit;
});
}
И финальный штрих - модифицируем метод configureRateLimiting
(по факту прокручиваем настройки в цикле и вызываем метод добавления лимита):
protected function configureRateLimiting()
{
collect($this->limitingParameters())->each(function ($params, $for) {
$this->addLimit(ThrottleConfig::create($for, $params));
});
}
Полный код обновлённого класса провайдера (phpdoc-и удалены для краткости):
<?php
namespace App\Providers;
use App\Casts\ThrottleConfig;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
public const HOME = '/home';
public function boot()
{
$this->configureRateLimiting();
$this->routes(function () {
Route::prefix('api')
->middleware('api')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web.php'));
});
}
protected function configureRateLimiting()
{
collect($this->limitingParameters())->each(function ($params, $for) {
$this->addLimit(ThrottleConfig::create($for, $params));
});
}
private function addLimit(ThrottleConfig $config): void
{
RateLimiter::for($config->getFor(), function (Request $request) use ($config) {
$limit = Limit::perMinute($config->getMaxAttempts())->by($request->user()?->id ?: $request->ip());
return $config->hasResponse()
? $limit->response(fn () => response($config->getResponse(), $config->getStatus()))
: $limit;
});
}
private function limitingParameters(): array
{
return config('throttle');
}
}
На этом на сегодня всё. Успехов!