Yandex SmartCaptcha в Laravel: серверная проверка токена

Yandex SmartCaptcha в Laravel: серверная проверка токена
содержание

Введение

Yandex SmartCaptcha — сервис Yandex Cloud для отсечения ботов. Схема работы простая: виджет на странице выдаёт одноразовый токен, сервер сайта передаёт его сервису на проверку и получает статус ok или failed. Задание пользователю показывается лишь тогда, когда запрос выглядит подозрительно. Капча не заменяет другие меры защиты, а дополняет их: throttle, валидацию и honeypot-поле.

Официальная документация описывает протокол, но не отвечает на вопросы, которые возникают при внедрении. Как встроить проверку в Laravel? Что делать, если сервис не отвечает? Как передать настоящий IP клиента за прокси? Как тестировать форму, не выходя в сеть? Дальше разобран сквозной пример «форма → правило валидации → тест», а также варианты для Livewire и API.

Понадобится аккаунт Yandex Cloud. Условия и цены приведены в прайс-листе SmartCaptcha; они меняются, поэтому в статье их нет. Код проверялся на PHP 8.5.9 (в composer.json указано ^8.3), Laravel 13.30.1 и Livewire 4.4.3.

Создание капчи и ключи

Капчу создают в консоли управления Yandex Cloud: раздел SmartCaptcha, кнопка «Создать капчу», затем имя и список сайтов, где она будет стоять (в примере из документации — my-shop.com). Допустим ли localhost для локальной разработки, документация не говорит. Надёжнее завести для отладки отдельную капчу с тестовым доменом.

Сервис выдаёт два ключа:

Ключ Префикс Где используется Секретность
Клиентский (client_key) ysc1_ В HTML страницы, в атрибуте data-sitekey Публичный
Серверный (server_key) ysc2_ Только на бэкенде, в параметре secret Приватный

Ключи создаются один раз и потом не меняются. Если серверный ключ утёк, по рекомендации документации нужно создать новую капчу и отказаться от старой.

Значения лежат в .env, а код обращается к ним через config/services.php:

SMARTCAPTCHA_ENABLED=true
SMARTCAPTCHA_CLIENT_KEY=ysc1_...
SMARTCAPTCHA_SERVER_KEY=ysc2_...
SMARTCAPTCHA_TIMEOUT=3
SMARTCAPTCHA_FAIL_OPEN=true
// config/services.php
'smartcaptcha' => [
    'enabled' => env('SMARTCAPTCHA_ENABLED', false),
    'client_key' => env('SMARTCAPTCHA_CLIENT_KEY'),
    'server_key' => env('SMARTCAPTCHA_SERVER_KEY'),
    'url' => 'https://smartcaptcha.cloud.yandex.ru/validate',
    'timeout' => (int) env('SMARTCAPTCHA_TIMEOUT', 3),
    'fail_open' => env('SMARTCAPTCHA_FAIL_OPEN', true),
],

Значение по умолчанию для timeout нужно обязательно: (int) null превращается в 0, а в Guzzle нуль означает «без ограничения». В таком случае зависший сервис капчи потянет за собой и форму сайта.

Виджет в Blade

Согласно документации, скрипт captcha.js ищет на странице все div с классом smart-captcha и рисует в них виджет. Настройки передаются data-атрибутами: data-sitekey (клиентский ключ), data-hl (язык: ru, en, be, kk, tt, uk, uz, tr) и data-callback (имя JS-функции, получающей токен). Тестовый режим (test) и невидимая капча (invisible) через data-атрибуты не задаются: они доступны только в расширенном методе, через window.smartCaptcha.render().

Виджет удобно оформить анонимным Blade-компонентом, который при выключенной проверке ничего не выводит:

{{-- resources/views/components/smart-captcha.blade.php --}}
@props(['lang' => 'ru'])

@if (config('services.smartcaptcha.enabled'))
    @once
        <script src="https://smartcaptcha.cloud.yandex.ru/captcha.js" defer></script>
    @endonce

    <div
        {{ $attributes->merge(['class' => 'smart-captcha']) }}
        data-sitekey="{{ config('services.smartcaptcha.client_key') }}"
        data-hl="{{ $lang }}"
    ></div>
@endif

В форму добавляется одна строка <x-smart-captcha />, а ошибка выводится обычным способом — через @error('smart-token'). Когда пользователь прошёл проверку, виджет сам кладёт в контейнер скрытое поле <input type="hidden" name="smart-token" value="...">, так что в запросе токен приходит под именем smart-token. Тот же компонент подходит для форм входа и регистрации из стартовых наборов (Стартовые наборы Laravel 12–13 и laravel/ui: React, Vue, Livewire).

Токен одноразовый, поэтому после возврата формы с ошибками валидации старый уже не сработает. При обычной отправке страница перезагружается, виджет рисуется заново, и проверку приходится пройти повторно. Вручную сбрасывать виджет нужно только там, где страница не перезагружается: при AJAX-отправке и в Livewire.

Серверная проверка токена

Проверка сводится к POST-запросу на https://smartcaptcha.cloud.yandex.ru/validate с телом x-www-form-urlencoded и параметрами secret, token и ip. Параметр ip не обязателен, но документация просит его передавать: так капча работает точнее. В ответе приходит JSON с полями status (ok или failed), message и, если проверка пройдена, host — сайт, на котором её прошли. Токен живёт 5 минут и принимается только один раз.

Отдельный пакет не нужен, достаточно Http::asForm(). Логику лучше вынести в сервис, чтобы правило валидации осталось коротким:

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class SmartCaptchaVerifier
{
    public function verify(string $token, ?string $ip = null): bool
    {
        try {
            $response = Http::asForm()
                ->timeout(config('services.smartcaptcha.timeout'))
                ->post(config('services.smartcaptcha.url'), array_filter([
                    'secret' => config('services.smartcaptcha.server_key'),
                    'token' => $token,
                    'ip' => $ip,
                ]));
        } catch (ConnectionException $e) {
            Log::warning('SmartCaptcha: сервис недоступен', ['error' => $e->getMessage()]);

            return $this->whenServiceFails();
        }

        if ($response->status() !== 200) {
            Log::error('SmartCaptcha: ответ не 200', [
                'status' => $response->status(),
                'message' => $response->json('message'),
            ]);

            return $this->whenServiceFails();
        }

        return $response->json('status') === 'ok';
    }

    // true — пропустить (fail-open), false — отклонить (fail-closed)
    private function whenServiceFails(): bool
    {
        return (bool) config('services.smartcaptcha.fail_open');
    }
}

Правило валидации реализует ValidationRule. Статический метод rules() отдаёт набор правил для поля, а при выключенной капче токен не требует:

namespace App\Rules;

use App\Services\SmartCaptchaVerifier;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class SmartCaptcha implements ValidationRule
{
    public static function rules(): array
    {
        return config('services.smartcaptcha.enabled')
            ? ['required', 'string', new self]
            : ['nullable', 'string'];
    }

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! app(SmartCaptchaVerifier::class)->verify($value, request()->ip())) {
            $fail('Не удалось подтвердить, что запрос отправил человек. Пройдите проверку ещё раз.');
        }
    }
}

В FormRequest правило подключается так:

public function rules(): array
{
    return [
        'name' => ['required', 'string', 'max:100'],
        'message' => ['required', 'string', 'max:2000'],
        'smart-token' => SmartCaptcha::rules(),
    ];
}

За один запрос токен проверяется один раз. Если то же правило подключено и к другим сценариям, следует учитывать: повторный вызов validate() с тем же токеном вернёт failed.

Сбой сервиса и таймаут

На случай недоступности сервиса или ответа не 200 документация даёт прямую рекомендацию: «ошибки HTTP-протокола (код ответа не 200) рекомендуется обрабатывать как ответ сервиса "status": "ok"». Идея в том, чтобы не задерживать и не блокировать пользователей. Это подход fail-open, и в примере он включается параметром SMARTCAPTCHA_FAIL_OPEN=true.

Обратный вариант, fail-closed (при сбое запрос отклоняется), из документации не вытекает: это решение самого разработчика. Что выбрать, зависит от формы:

Подход Плюс Минус Где уместен
fail-open Форма работает при сбое капчи На время сбоя защиты нет Обратная связь, комментарии
fail-closed Защита действует всегда При сбое форма недоступна Вход, регистрация, платежи

При любом выборе сбой стоит писать в лог и не считать капчу единственным барьером: на маршруте остаётся throttle (например, throttle:5,1), а записи в логе позволяют заметить проблему раньше, чем придут жалобы.

Есть подвох, о котором документация молчит. Если серверный ключ неверный, сервис отвечает не 200, а 403. Запрос к реальному адресу с ключом-заглушкой вернул 403 и тело {"status":"failed","message":"Authentication failed. Invalid secret."}. При fail-open и правиле «не 200 значит ok» опечатка в SMARTCAPTCHA_SERVER_KEY незаметно отключит капчу целиком. Поэтому сервис пишет любой ответ не 200 в лог на уровне error вместе со статусом и message, и именно на такую запись стоит повесить оповещение.

Таймаут нужно подбирать по собственным замерам в целевой среде. При пробном запуске из Docker запрос с таймаутом в 1 секунду к реальному адресу завершился ошибкой cURL error 28: слишком жёсткий лимит превращает исправную работу сервиса в «сбой». Три секунды в примере — точка отсчёта, а не рекомендация.

IP клиента за прокси

Параметр ip берётся из $request->ip(). Если приложение работает за балансировщиком или reverse proxy (nginx, Cloudflare) и доверенные прокси не настроены, Laravel вернёт адрес самого прокси, и сервис получит неверный IP. Доверенные прокси задаются в bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
    // '*' доверяет любому прокси; при известных адресах лучше перечислить их
    $middleware->trustProxies(at: '*');
})

Значение '*' годится, если приложение доступно только через балансировщик. Когда сервер открыт напрямую, клиент может подделать заголовок X-Forwarded-For, и тогда нужен явный список адресов. В тесте набора (в статье не приведён) запрос с REMOTE_ADDR=10.0.0.1 и заголовком X-Forwarded-For: 203.0.113.7 передаёт сервису именно 203.0.113.7.

Livewire

Форма Livewire уходит без перезагрузки страницы, поэтому три вещи приходится делать самостоятельно: защитить контейнер от перерисовки, положить токен в свойство компонента и сбросить виджет после отправки.

class ContactForm extends Component
{
    public string $name = '';
    public string $message = '';
    public string $captchaToken = '';

    protected function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:100'],
            'message' => ['required', 'string', 'max:2000'],
            'captchaToken' => SmartCaptcha::rules(),
        ];
    }

    public function submit(): void
    {
        try {
            $this->validate();
        } catch (ValidationException $e) {
            // токен уже потрачен на проверке, виджет нужно сбросить
            $this->dispatch('smartcaptcha-reset');

            throw $e;
        }

        // здесь отправка письма или сохранение обращения

        $this->reset('name', 'message', 'captchaToken');
        $this->dispatch('smartcaptcha-reset');
    }
}

В шаблоне контейнер обёрнут в wire:ignore, скрипт подключается расширенным методом (render=onload&onload=...), а токен попадает в свойство из callback. Методы render(), reset() и subscribe() (событие token-expired) описаны в документации в разделе «Методы виджета»:

<div wire:ignore data-captcha></div>
@error('captchaToken') <p class="error">{{ $message }}</p> @enderror

@assets
    <script>
        window.smartCaptchaLoaded = () => window.dispatchEvent(new Event('smartcaptcha:ready'));
    </script>
    <script src="https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&onload=smartCaptchaLoaded" defer></script>
@endassets

@script
    <script>
        const init = () => {
            const widgetId = window.smartCaptcha.render($wire.$el.querySelector('[data-captcha]'), {
                sitekey: @js(config('services.smartcaptcha.client_key')),
                hl: 'ru',
                callback: (token) => { $wire.captchaToken = token; },
            });

            // токен живёт 5 минут: по истечении срока поле очищается
            window.smartCaptcha.subscribe(widgetId, 'token-expired', () => { $wire.captchaToken = ''; });
            $wire.on('smartcaptcha-reset', () => window.smartCaptcha.reset(widgetId));
        };

        window.smartCaptcha ? init() : window.addEventListener('smartcaptcha:ready', init, { once: true });
    </script>
@endscript

Свойство captchaToken не стоит связывать через wire:model.live и помечать атрибутом #[Validate]. Иначе валидация на каждое обновление израсходует одноразовый токен раньше, чем пользователь нажмёт кнопку отправки. Проверять токен нужно только внутри submit().

API и SPA

В API правило остаётся тем же, отличается лишь имя поля: клиент присылает токен в JSON под любым согласованным именем, например smart_captcha_token. Если проверка не пройдена, возвращается обычный ответ 422 с ошибкой в этом поле.

class AuthLoginRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email' => ['required', 'email'],
            'password' => ['required', 'string'],
            'smart_captcha_token' => SmartCaptcha::rules(),
        ];
    }
}

SPA берёт токен из callback виджета и добавляет его в тело запроса. На маршрут входа стоит повесить throttle. Если API работает с токенами доступа (см. авторизацию по Bearer-токену в Laravel), капча прикрывает только выдачу токена, а последующие запросы с ним проверяются как обычно.

Пакет или своя реализация

Собственная реализация — это два небольших класса без лишних зависимостей. Готовые пакеты оправданы, если нужны готовые Blade-компоненты или единый клиент к другим API Yandex Cloud. Данные в таблице взяты из Packagist на 30.09.2026:

Пакет Последний релиз Требования Описание на Packagist
dsoloview/laravel-yandex-captcha 2.2.0, 21.10.2025 Laravel ^10 (в последнем релизе) Yandex captcha integration for Laravel
vvb/yandex-smart-captcha v1.0.2, 07.08.2026 PHP ^8.1, Laravel 10–13 Yandex Smart Captcha integration for Laravel
tigusigalpa/yandex-smartcaptcha-php v1.0.0, 29.11.2025 PHP ^8.0, Guzzle PHP SDK: управление капчами, валидация, интеграция с Laravel
Свой код — Laravel 13, PHP 8.3+ Сервис, правило и компонент из статьи

Перед выбором пакета стоит проверить, какие версии Laravel и PHP заявлены в свежем релизе и как он ведёт себя при сбое сервиса: единого стандарта для fail-open и fail-closed нет.

Тесты

Форму можно тестировать без сети: Http::fake() подменяет ответ на адрес smartcaptcha.cloud.yandex.ru/*, а Http::preventStrayRequests() роняет тест, если код пошлёт незапланированный запрос.

protected function setUp(): void
{
    parent::setUp();

    config([
        'services.smartcaptcha.enabled' => true,
        'services.smartcaptcha.client_key' => 'ysc1_client_placeholder',
        'services.smartcaptcha.server_key' => 'ysc2_server_placeholder',
        'services.smartcaptcha.fail_open' => true,
    ]);

    Http::preventStrayRequests();
}

// $this->form() — вспомогательный метод теста с полями name, message и smart-token
public function test_failed_status_rejects(): void
{
    Http::fake(['smartcaptcha.cloud.yandex.ru/*' => Http::response(['status' => 'failed', 'message' => ''])]);

    $this->post('/contact', $this->form())->assertSessionHasErrors('smart-token');
}

public function test_server_error_fail_closed(): void
{
    config(['services.smartcaptcha.fail_open' => false]);
    Http::fake(['smartcaptcha.cloud.yandex.ru/*' => Http::response('', 500)]);

    $this->post('/contact', $this->form())->assertSessionHasErrors('smart-token');
}

public function test_connection_error_fail_open(): void
{
    Http::fake(['smartcaptcha.cloud.yandex.ru/*' => Http::failedConnection()]);

    $this->post('/contact', $this->form())->assertSessionHasNoErrors();
}

public function test_disabled_makes_no_request(): void
{
    config(['services.smartcaptcha.enabled' => false]);
    Http::fake();

    $this->post('/contact', $this->form(['smart-token' => null]))->assertSessionHasNoErrors();

    Http::assertNothingSent();
}

Сценарий с повторным токеном удобно собрать на Http::sequence(): первый ответ ok, второй failed. Содержимое запроса проверяется через Http::assertSent() — секретный ключ, токен и IP приходят в теле формы. Если нужно отключить проверку целиком, достаточно параметра services.smartcaptcha.enabled: правило вернёт nullable, и обращений к сети не будет.

Прогон 15 тестов полного набора автора (выше показаны четыре; в наборе также форма, повторный токен, сбои, 403, API, Livewire, IP за прокси) завершился так:

Tests:    15 passed (40 assertions)

Тесты подтверждают серверную часть, но не работу настоящего виджета в браузере: для этого нужны реальный клиентский ключ и зарегистрированный домен.

Типичные ошибки

Симптом или ответ Вероятная причина
Invalid or expired Token. Токен использован повторно, прошло больше 5 минут или токен повреждён
Authentication failed. No secret provided. В запросе нет secret (пустой SMARTCAPTCHA_SERVER_KEY, кэш конфигурации). В документации текст записан иначе: Secret has not provided.
Authentication failed. Invalid secret. (HTTP 403) Неверный серверный ключ, например ключ от другой капчи
status: failed и пустой message Сервис счёл запрос ботом; это штатный отказ, а не ошибка запроса
status: ok, но host пустой По документации может означать блокировку облака или внутренний сбой сервиса
Виджет не появляется Домен не указан в списке сайтов капчи, блокировщик рекламы, Content-Security-Policy без домена smartcaptcha.cloud.yandex.ru
Задание не показывается при проверке Сервис показывает его только подозрительным запросам; документация советует открыть страницу в режиме инкогнито или через VPN

Поле message предназначено только для диагностики, так рекомендует документация: условия в коде должны опираться на status.

Чек-лист

  • Серверный ключ хранится только в .env и не попадает в HTML, репозиторий и логи.
  • У timeout есть значение по умолчанию, подобранное по собственным замерам.
  • В запрос передаётся ip, а доверенные прокси настроены.
  • Политика при сбое (fail-open или fail-closed) выбрана осознанно для каждой формы.
  • Ответы не 200 пишутся в лог на уровне error, включая 403 при неверном ключе.
  • Токен проверяется один раз за запрос; в Livewire виджет сбрасывается после отправки и после ошибки валидации.
  • Маршрут защищён throttle, а тесты используют Http::fake() и Http::preventStrayRequests().

Итог

Чтобы подключить SmartCaptcha к Laravel, хватает Http::asForm(), правила ValidationRule и Blade-компонента: серверная часть укладывается в несколько десятков строк. Главные решения остаются за разработчиком: как вести себя при сбое сервиса, какой таймаут выбрать и как сбрасывать виджет без перезагрузки страницы. Отдельно стоит помнить про 403 при неверном ключе: правило «не 200 значит ok» не должно втихую отключать защиту.

Похожие