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

 

На этом на сегодня всё. Успехов!