Laravel 8: обмеження трафіку за допомогою RateLimiter

Laravel 8: обмеження трафіку за допомогою RateLimiter

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');
    }
}

 

На цьому на сьогодні все. Успіхів!