
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');
}
}
На цьому на сьогодні все. Успіхів!