Урок 05

Аутентификация в WebSocket

Проблема

HTTP-авторизация не работает в WebSocket:

  • Нет cookie (обычно).
  • Нет middleware как в HTTP.
  • Соединение открыто один раз — на часы.

Решение: токен при подключении.

Варианты

1. Токен в handshake auth:

io(url, { auth: { token: 'xxx' } });

2. Токен в query:

io(url, { query: { token: 'xxx' } });

3. Cookie (если same-origin):

io(url, { withCredentials: true });

Рекомендую: auth — самый чистый.

Middleware авторизации

Сервер:

import { Server } from 'socket.io';
import jwt from 'jsonwebtoken';

const io = new Server(8080, {
    cors: { origin: 'http://localhost:3000', credentials: true },
});

io.use((socket, next) => {
    const token = socket.handshake.auth.token;

    if (!token) {
        return next(new Error('UNAUTHORIZED'));
    }

    try {
        const payload = jwt.verify(token, process.env.JWT_SECRET);
        socket.data.user = {
            id: payload.sub,
            email: payload.email,
        };
        next();
    } catch (e) {
        next(new Error('INVALID_TOKEN'));
    }
});

io.on('connection', (socket) => {
    console.log('Подключился:', socket.data.user.id);
});

Что происходит:

  1. io.use — middleware до connection.
  2. Проверяет токен.
  3. Сохраняет юзера в socket.data.
  4. next() — продолжить, next(error) — отклонить.

Обработка ошибок на клиенте

Сервер:

io.use((socket, next) => {
    // ...
    next(new Error('INVALID_TOKEN'));
});

Клиент:

const socket = io(url, { auth: { token } });

socket.on('connect_error', (err) => {
    if (err.message === 'INVALID_TOKEN') {
        // Токен протух — обновить и переподключиться
        refreshToken().then((newToken) => {
            socket.auth.token = newToken;
            socket.connect();
        });
    }
});

Что даёт: автоматическое обновление токена.

JWT vs сессии

JWT:

socket.handshake.auth.token

Плюсы:

  • Stateless. Работает на нескольких серверах.
  • Стандарт для API.
  • Лёгкий в передаче.

Минусы:

  • Отозвать сложно.
  • Токен живёт до истечения.

Сессии:

socket.request.headers.cookie

Плюсы:

  • Отозвать легко.
  • Обновляется автоматически.

Минусы:

  • Только same-origin (без доп. настроек).
  • Не для мобильных.

Правило: SPA на том же домене — cookies. Всё остальное — JWT.

Cookies для SPA

Если фронт на том же домене:

const io = new Server(8080, {
    cors: {
        origin: 'http://localhost:3000',
        credentials: true,
    },
});

io.use((socket, next) => {
    const cookies = socket.request.headers.cookie;

    if (!cookies) return next(new Error('NO_COOKIES'));

    // Парсить cookies → sessionId → проверить сессию
});

Клиент:

io(url, { withCredentials: true });

⚠️ на клиенте обязательно withCredentials.

Laravel Sanctum

Если бэк на Laravel:

io.use(async (socket, next) => {
    const token = socket.handshake.auth.token;

    if (!token) return next(new Error('UNAUTHORIZED'));

    try {
        const response = await fetch(`${LARAVEL_URL}/api/user`, {
            headers: { Authorization: `Bearer ${token}` },
        });

        if (!response.ok) throw new Error('Unauthorized');

        socket.data.user = await response.json();
        next();
    } catch (e) {
        next(new Error('UNAUTHORIZED'));
    }
});

Что даёт: Sanctum-токен проверяется через Laravel API.

⚠️ на каждый коннект — запрос. Кешируй результат.

Кеширование токенов

const tokenCache = new Map();

async function verifyToken(token) {
    if (tokenCache.has(token)) {
        const { user, expires } = tokenCache.get(token);
        if (Date.now() < expires) return user;
        tokenCache.delete(token);
    }

    const response = await fetch(`${LARAVEL_URL}/api/user`, {
        headers: { Authorization: `Bearer ${token}` },
    });

    if (!response.ok) throw new Error('Unauthorized');

    const user = await response.json();
    tokenCache.set(token, { user, expires: Date.now() + 60_000 });

    return user;
}

Что даёт: один запрос в минуту вместо каждого коннекта.

Проверка прав на событие

Не только при подключении:

socket.on('post:delete', async ({ postId }, cb) => {
    const post = await Post.findByPk(postId);

    if (!post) return cb({ error: 'Не найдено', code: 'NOT_FOUND' });

    if (post.userId !== socket.data.user.id) {
        return cb({ error: 'Нет прав', code: 'FORBIDDEN' });
    }

    await post.destroy();
    cb({ ok: true });
});

Правило: каждое событие — проверка.

Session revocation

Проблема: JWT нельзя отозвать.

Решение: проверять сессию в БД.

io.use(async (socket, next) => {
    const token = socket.handshake.auth.token;
    const payload = jwt.verify(token, SECRET);

    // Проверить, что сессия ещё активна
    const session = await Session.findByPk(payload.sid);

    if (!session || session.revokedAt) {
        return next(new Error('SESSION_REVOKED'));
    }

    socket.data.user = payload;
    next();
});

Периодическая проверка:

setInterval(async () => {
    for (const [id, socket] of io.sockets.sockets) {
        const session = await Session.findByPk(socket.data.sid);
        if (!session || session.revokedAt) {
            socket.disconnect(true);
        }
    }
}, 60_000);

Logout

Клиент:

socket.disconnect();

Сервер:

socket.on('logout', () => {
    socket.disconnect(true);
});

Что даёт: соединение закрыто, клиент не подключится без токена.

Проверка через middleware на namespace

const admin = io.of('/admin');

admin.use((socket, next) => {
    const user = socket.data.user;
    if (user?.role !== 'admin') {
        return next(new Error('FORBIDDEN'));
    }
    next();
});

Что даёт: отдельная авторизация для namespace.

Практика: полная авторизация

Сервер:

import { Server } from 'socket.io';
import jwt from 'jsonwebtoken';
import { z } from 'zod';

const io = new Server(8080, {
    cors: { origin: 'http://localhost:3000', credentials: true },
});

// Middleware авторизации
io.use(async (socket, next) => {
    const token = socket.handshake.auth?.token;

    if (!token) {
        return next(new Error('UNAUTHORIZED'));
    }

    try {
        const payload = jwt.verify(token, process.env.JWT_SECRET);

        const user = await User.findByPk(payload.sub);
        if (!user) return next(new Error('UNAUTHORIZED'));

        socket.data.user = { id: user.id, name: user.name, role: user.role };
        socket.join(`user:${user.id}`);

        next();
    } catch (e) {
        next(new Error('INVALID_TOKEN'));
    }
});

io.on('connection', (socket) => {
    console.log(`${socket.data.user.name} подключился`);

    io.emit('user:online', { userId: socket.data.user.id });

    socket.on('disconnect', async () => {
        const sockets = await io.in(`user:${socket.data.user.id}`).fetchSockets();

        if (sockets.length === 0) {
            io.emit('user:offline', { userId: socket.data.user.id });
        }
    });
});

Клиент:

const socket = io('http://localhost:8080', {
    auth: { token: localStorage.getItem('token') },
});

socket.on('connect_error', (err) => {
    if (err.message === 'INVALID_TOKEN') {
        refreshToken().then(() => {
            socket.auth.token = getToken();
            socket.connect();
        });
    } else if (err.message === 'UNAUTHORIZED') {
        window.location.href = '/login';
    }
});

Частые ошибки

1. Токен в query (небезопасно):

io(url, { query: { token } });   // токен в логах сервера

// Правильно:
io(url, { auth: { token } });

2. Забыли withCredentials:

io(url);   // cookies не отправятся

// Правильно:
io(url, { withCredentials: true });

3. Один next(error) без сообщения:

next(new Error());   // клиент не поймёт

// Правильно:
next(new Error('UNAUTHORIZED'));

4. Нет refresh на клиенте:

Токен истёк через час → чат умер

Решение: connect_error → refresh → reconnect.

5. Проверка только при connect:

Права отозвали, а соединение живёт

Решение: проверка на каждом событии.

Итог

  • JWT в auth — основной способ.
  • Middleware io.use — авторизация.
  • socket.data.user — доступ к юзеру.
  • next(error) — отклонить.
  • connect_error — на клиенте refresh.
  • Проверка на каждом событии — обязательно.
  • Cookies — для SPA на том же домене.
  • user:${userId} — комната юзера.

Дальше — real-time в Laravel (Reverb + Echo).