javascript

Каналы и авторизация в Centrifugo: как безопасно подключить real-time в Laravel

  • суббота, 16 мая 2026 г. в 00:00:06
https://habr.com/ru/articles/1035472/

В предыдущей статье мы разобрали архитектуру Laravel + Centrifugo: Laravel отвечает за бизнес-логику, права доступа и события, Centrifugo — за WebSocket-соединения, каналы и доставку сообщений, а frontend — за подключение, подписку и обновление интерфейса. Теперь нужно разобрать самый важный слой этой схемы — каналы и авторизацию.

Real-time без авторизации опасен. Если пользователь может подписаться на чужой канал, он может получать чужие уведомления, статусы заказов, сообщения, события админки или финансовые обновления. В обычном HTTP API такая ошибка выглядела бы как доступ к чужому endpoint. В WebSocket-архитектуре ошибка такая же, просто выглядит менее очевидно.

Поэтому при интеграции Centrifugo и Laravel нужно разделять две задачи:

  1. Аутентификация подключения: кто этот пользователь?

  2. Авторизация подписки: имеет ли этот пользователь право слушать конкретный канал?

Для первой задачи используется connection token. Для второй — subscription token. Оба механизма строятся вокруг JWT, но решают разные задачи. Centrifugo использует JWT от backend-приложения для безопасной аутентификации real-time подключения и определения пользователя в системе.  

Что такое канал в Centrifugo

Канал в Centrifugo — это логический маршрут доставки сообщений. Backend публикует сообщение в канал, а все клиенты, подписанные на этот канал, получают публикацию. Centrifugo работает по модели PUB/SUB: есть издатели сообщений, есть подписчики, а канал связывает их между собой.  

Примеры каналов:

public:news
public:stats
$users:15
$orders:7821
$admin:payments
chat:room:45

Для Laravel-разработчика канал можно воспринимать как адрес доставки real-time события. Например, если заказ 7821 изменил статус, Laravel может опубликовать событие в канал заказа или в персональный канал пользователя.

Пример публикации:

{
  "channel": "$users:15",
  "data": {
    "type": "order.status.changed",
    "orderId": 7821,
    "status": "paid"
  }
}

Сам по себе канал не является безопасным. Это просто строка. Если frontend может свободно подписаться на любую строку, значит он может попробовать подписаться на чужой канал. Поэтому безопасность строится не на названии канала, а на проверке прав подписки.

Публичные и приватные каналы

В Centrifugo можно использовать публичные и приватные каналы.

Публичные каналы подходят для данных, которые действительно можно показывать всем подключённым клиентам. Например:

public:news
public:system-status
public:landing-counter

Такие каналы могут использоваться для публичных счётчиков, общей ленты событий, демонстрационных страниц, открытых статусов или данных без персональной чувствительности.

Приватные каналы нужны для персональных и ограниченных данных:

$users:15
$orders:7821
$admin:payments
$moderation:queue

В Centrifugo каналы, начинающиеся с символа $, считаются приватными. Для подписки на такой канал backend должен выдать отдельный токен подписки, чтобы можно было контролировать право пользователя на доступ к каналу.  

Это важный принцип. Подключиться к Centrifugo — ещё не значит получить право слушать все каналы. Пользователь может быть успешно аутентифицирован, но это не даёт ему доступ к чужим заказам, чужим уведомлениям или административным событиям.

Для Laravel-проекта безопаснее начинать именно с приватных каналов. Публичные каналы стоит использовать только там, где утечка данных невозможна по определению.

Connection token: кто подключается к Centrifugo

Connection token нужен для подключения пользователя к Centrifugo. Его задача — доказать Centrifugo, что клиент действительно связан с пользователем вашего Laravel-приложения.

Типовой поток такой:

1. Пользователь авторизован в Laravel.
2. Frontend запрашивает у Laravel connection token.
3. Laravel генерирует JWT с идентификатором пользователя.
4. Frontend передаёт этот token при подключении к Centrifugo.
5. Centrifugo принимает подключение и знает user ID.

Пример endpoint-а в Laravel:

namespace App\Http\Controllers\Realtime;

use Firebase\JWT\JWT;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class CentrifugoConnectionTokenController
{
    public function __invoke(Request $request): JsonResponse
    {
        $user = $request->user();

        $token = JWT::encode(
            payload: [
                'sub' => (string) $user->id,
                'exp' => time() + 300,
            ],
            key: config('services.centrifugo.token_secret'),
            alg: 'HS256',
        );

        return response()->json(data: [
            'token' => $token,
        ]);
    }
}

Поле sub указывает идентификатор пользователя. Время жизни токена лучше делать ограниченным. Бессрочные токены для real-time подключения — плохая привычка. Они удобны ровно до первого инцидента.

Маршрут должен быть закрыт обычной Laravel-авторизацией:

use App\Http\Controllers\Realtime\CentrifugoConnectionTokenController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->get(
    '/realtime/connection-token',
    CentrifugoConnectionTokenController::class,
);

На frontend это может выглядеть так:

import { Centrifuge } from 'centrifuge';

const centrifuge = new Centrifuge('wss://example.com/connection/websocket', {
    getToken: async function () {
        const response = await fetch('/realtime/connection-token', {
            headers: {
                Accept: 'application/json',
            },
            credentials: 'same-origin',
        });

        const data = await response.json();

        return data.token;
    },
});

centrifuge.connect();

Connection token отвечает только на вопрос: «Кто подключился?». Он не должен автоматически давать доступ ко всем каналам.

Subscription token: право доступа к конкретному каналу

Subscription token нужен для подписки на конкретный канал. Это отдельная проверка. Centrifugo поддерживает механизм channel JWT authorization: клиент передаёт subscription token при подписке, а корректный токен сообщает Centrifugo, что подписку нужно принять.  

Это принципиальное отличие от connection token.

Connection token:

Пользователь 15 действительно подключается к Centrifugo.

Subscription token:

Пользователь 15 действительно имеет право слушать канал $users:15.

Если эту разницу игнорировать, появляется типичная уязвимость: пользователь авторизован, но может подписаться на чужой канал.

Пример endpoint-а для выдачи subscription token:

namespace App\Http\Controllers\Realtime;

use Firebase\JWT\JWT;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class CentrifugoSubscriptionTokenController
{
    public function __invoke(Request $request): JsonResponse
    {
        $user = $request->user();
        $channel = (string) $request->input(key: 'channel');

        abort_if(
            boolean: $channel !== '$users:' . $user->id,
            code: 403,
        );

        $token = JWT::encode(
            payload: [
                'sub' => (string) $user->id,
                'channel' => $channel,
                'exp' => time() + 300,
            ],
            key: config('services.centrifugo.token_secret'),
            alg: 'HS256',
        );

        return response()->json(data: [
            'token' => $token,
        ]);
    }
}

Маршрут также должен быть закрыт middleware auth:

use App\Http\Controllers\Realtime\CentrifugoSubscriptionTokenController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->post(
    '/realtime/subscription-token',
    CentrifugoSubscriptionTokenController::class,
);

Главный смысл этого кода — не генерация JWT. Главный смысл — проверка перед генерацией:

$channel !== '$users:' . $user->id

Пользователь с ID 15 может получить token только для канала $users:15. Если он попросит $users:16, Laravel вернёт 403 Forbidden.

Иначе зачем вообще была вся авторизация.

Подписка frontend на приватный канал

На frontend подписка на приватный канал должна получать subscription token с backend-а.

Пример:

const userId = 15;
const userChannel = `$users:${userId}`;

const subscription = centrifuge.newSubscription(userChannel, {
    getToken: async function () {
        const response = await fetch('/realtime/subscription-token', {
            method: 'POST',
            headers: {
                Accept: 'application/json',
                'Content-Type': 'application/json',
            },
            credentials: 'same-origin',
            body: JSON.stringify({
                channel: userChannel,
            }),
        });

        if (response.status === 403) {
            throw new Error('Subscription forbidden');
        }

        const data = await response.json();

        return data.token;
    },
});

subscription.on('publication', function (context) {
    if (context.data.type === 'order.status.changed') {
        updateOrderStatus(
            context.data.orderId,
            context.data.status,
        );
    }
});

subscription.subscribe();

Этот код демонстрирует механику, но есть важное уточнение: userId на frontend не должен быть источником истины. Пользователь может изменить JavaScript в браузере, подменить channel name и запросить другой канал. Поэтому Laravel обязан проверять канал на своей стороне.

Более аккуратный вариант — не давать frontend самому конструировать чувствительные каналы. Laravel может вернуть список доступных каналов вместе с начальным состоянием страницы:

{
  "user": {
    "id": 15,
    "name": "Max"
  },
  "realtime": {
    "channels": [
      "$users:15"
    ]
  }
}

Но даже в этом случае проверка subscription token на стороне Laravel всё равно обязательна. Данные, пришедшие от клиента, нельзя считать доверенными только потому, что пять секунд назад backend сам их отдал.

Проверка прав на стороне Laravel

Для персонального канала $users:{id} проверка простая: ID в канале должен совпадать с ID текущего пользователя.

Но реальные проекты редко ограничиваются только персональными каналами. Часто появляются каналы заказов, чатов, админских разделов, проектов, команд и организаций.

Примеры правил:

$users:15
Пользователь может слушать только свой канал.

$orders:7821
Пользователь может слушать канал заказа, только если заказ принадлежит ему.

chat:room:45
Пользователь может слушать комнату, только если он участник чата.

$admin:payments
Пользователь может слушать канал, только если у него есть административная роль.

project:88
Пользователь может слушать канал, только если он состоит в проекте.

Пример проверки канала заказа:

namespace App\Http\Controllers\Realtime;

use App\Models\Order;
use Firebase\JWT\JWT;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class CentrifugoOrderSubscriptionTokenController
{
    public function __invoke(Request $request): JsonResponse
    {
        $user = $request->user();
        $channel = (string) $request->input(key: 'channel');

        abort_if(
            boolean: ! Str::startsWith($channel, '$orders:'),
            code: 403,
        );

        $orderId = (int) Str::after($channel, '$orders:');

        $order = Order::query()->findOrFail($orderId);

        abort_if(
            boolean: $order->user_id !== $user->id,
            code: 403,
        );

        $token = JWT::encode(
            payload: [
                'sub' => (string) $user->id,
                'channel' => $channel,
                'exp' => time() + 300,
            ],
            key: config('services.centrifugo.token_secret'),
            alg: 'HS256',
        );

        return response()->json(data: [
            'token' => $token,
        ]);
    }
}

В production лучше не делать один огромный контроллер с if на все типы каналов. Нормальный вариант — вынести проверку в отдельный resolver или policy-слой.

Например:

namespace App\Services\Realtime;

use App\Models\User;

class RealtimeChannelAuthorizationService
{
    public function canSubscribe(
        User $user,
        string $channel,
    ): bool {
        if ($channel === '$users:' . $user->id) {
            return true;
        }

        if ($this->isOrderChannelAllowed(
            user: $user,
            channel: $channel,
        )) {
            return true;
        }

        return false;
    }

    private function isOrderChannelAllowed(
        User $user,
        string $channel,
    ): bool {
        // Проверка канала заказа через модель, policy или repository.
        return false;
    }
}

Контроллер тогда становится чище:

namespace App\Http\Controllers\Realtime;

use App\Services\Realtime\RealtimeChannelAuthorizationService;
use Firebase\JWT\JWT;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class CentrifugoSubscriptionTokenController
{
    public function __construct(
        private readonly RealtimeChannelAuthorizationService $authorizationService,
    ) {
    }

    public function __invoke(Request $request): JsonResponse
    {
        $user = $request->user();
        $channel = (string) $request->input(key: 'channel');

        abort_if(
            boolean: ! $this->authorizationService->canSubscribe(
                user: $user,
                channel: $channel,
            ),
            code: 403,
        );

        $token = JWT::encode(
            payload: [
                'sub' => (string) $user->id,
                'channel' => $channel,
                'exp' => time() + 300,
            ],
            key: config('services.centrifugo.token_secret'),
            alg: 'HS256',
        );

        return response()->json(data: [
            'token' => $token,
        ]);
    }
}

Так архитектура остаётся расширяемой. Можно добавлять новые типы каналов, не превращая endpoint выдачи токенов в свалку условий.

Пример: пользователь может подписаться только на свой канал

Разберём самый базовый и самый полезный сценарий: пользовательские уведомления через канал $users:{id}.

Есть пользователь с ID 15. Его персональный real-time канал:

$users:15

Laravel публикует туда события:

$publisher->publish(
    channel: '$users:' . $user->id,
    data: [
        'type' => 'notification.created',
        'notificationId' => $notification->id,
    ],
);

Frontend пользователя подписывается на этот канал:

const subscription = centrifuge.newSubscription('$users:15', {
    getToken: getSubscriptionToken,
});

Пользователь пытается подменить канал на $users:16. Frontend отправляет запрос:

{
  "channel": "$users:16"
}

Laravel проверяет:

abort_if(
    boolean: $channel !== '$users:' . $request->user()->id,
    code: 403,
);

Если текущий пользователь — 15, доступ к $users:16 запрещён.

Это минимальная, но принципиальная защита.

Ошибки, которые чаще всего ломают безопасность real-time

Первая ошибка — считать, что если пользователь авторизован в Laravel, он автоматически имеет право на любой канал Centrifugo. Нет. Авторизация подключения и авторизация подписки — разные задачи.

Вторая ошибка — доверять имени канала с frontend. Клиент может прислать любой channel name. Laravel должен проверить его через текущего пользователя, модель, роль, policy или доменное правило.

Третья ошибка — делать каналы публичными «для простоты». Для публичных данных это допустимо. Для пользовательских уведомлений, заказов, чатов, платежей и админских событий — нет.

Четвёртая ошибка — выдавать слишком долгоживущие токены. Чем дольше живёт token, тем дольше сохраняется риск при его утечке или изменении прав пользователя.

Пятая ошибка — отправлять в канал больше данных, чем нужно. Даже правильно авторизованный канал не должен получать лишние поля. Payload должен быть минимальным.

Практические правила проектирования каналов

Для Laravel + Centrifugo можно использовать следующие правила.

Персональные события отправлять в канал пользователя:

$users:{userId}

События конкретной сущности отправлять в канал сущности только при наличии понятной проверки доступа:

$orders:{orderId}
chat:room:{roomId}
project:{projectId}

Административные события отделять от пользовательских:

$admin:orders
$admin:payments
$admin:risk-events

Не использовать guessable channel name как единственную защиту. Канал $orders:7821 легко угадать. Защита должна быть не в сложности строки, а в backend-проверке.

Для чувствительных данных начинать с приватных каналов. Публичность должна быть осознанным решением, а не настройкой по умолчанию.

Заключение

Каналы и авторизация — центральная часть безопасной real-time архитектуры на Laravel и Centrifugo. Канал определяет, куда доставляется сообщение. Connection token подтверждает, кто подключается к Centrifugo. Subscription token подтверждает, что этот пользователь имеет право слушать конкретный канал.

Laravel должен оставаться владельцем бизнес-правил и прав доступа. Именно backend решает, может ли пользователь подписаться на $users:{id}$orders:{orderId}chat:room:{id} или $admin:payments. Centrifugo доставляет сообщения, но не должен подменять доменную авторизацию приложения.

Без этой границы real-time быстро становится уязвимостью. С этой границей он становится нормальной частью архитектуры: безопасной, расширяемой и понятной для backend-разработчиков.