• Время чтения ~3 мин
  • 11.08.2023

В конце предыдущей статьи я упоминал 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');
    }
}

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

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Об авторе CrazyBoy49z
WORK EXPERIENCE
Контакты
Ukraine, Lutsk
+380979856297