Аутентификация в 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);
});
Что происходит:
io.use— middleware до connection.- Проверяет токен.
- Сохраняет юзера в
socket.data. 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).