fix(security): пер-юзер лимиты ИИ + SSE через одноразовый тикет (Спринт1 #5,#6)
#5 rate-limit (byUser) на дорогих LLM-эндпоинтах: /assistant/ask (20/мин), /assistant/flashcards (10/мин), /imggen (20/мин) — поверх cooldown/дневного лимита. Защита от «сжигания» бюджета провайдера одним аккаунтом. #6 SSE больше не таскает JWT в URL: добавлен authed /notifications/stream-ticket (одноразовый тикет, TTL 30с), клиент берёт тикет заголовком и подключается с ?ticket=. ?token= оставлен как временный фоллбэк для старых клиентов. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,28 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db/db');
|
||||
const { addClient, removeClient } = require('../sse');
|
||||
|
||||
/* Одноразовые тикеты для SSE: EventSource не умеет слать заголовки, поэтому раньше
|
||||
JWT шёл в ?token= (утекал в логи/Referer). Теперь клиент берёт короткоживущий
|
||||
одноразовый тикет authed-запросом и подключается с ?ticket=. */
|
||||
const _tickets = new Map(); // ticket -> { userId, exp }
|
||||
const TICKET_TTL = 30_000;
|
||||
function _consumeTicket(t) {
|
||||
const e = _tickets.get(t);
|
||||
if (!e) return null;
|
||||
_tickets.delete(t); // одноразовый
|
||||
return Date.now() > e.exp ? null : e.userId;
|
||||
}
|
||||
setInterval(() => { const now = Date.now(); for (const [t, e] of _tickets) if (now > e.exp) _tickets.delete(t); }, 60_000).unref();
|
||||
|
||||
/* ── GET /api/notifications/stream-ticket ── (authed) выдаёт тикет для SSE ── */
|
||||
function streamTicket(req, res) {
|
||||
const t = crypto.randomBytes(18).toString('base64url');
|
||||
_tickets.set(t, { userId: req.user.id, exp: Date.now() + TICKET_TTL });
|
||||
res.json({ ticket: t });
|
||||
}
|
||||
|
||||
const _stmts = {
|
||||
list: db.prepare('SELECT id, type, message, link, is_read, created_at FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50'),
|
||||
markOne: db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?'),
|
||||
@@ -30,19 +51,28 @@ function markAllRead(req, res) {
|
||||
|
||||
/* ── GET /api/notifications/stream ── SSE (auth via ?token=JWT) ─────────── */
|
||||
function stream(req, res) {
|
||||
const token = req.query.token;
|
||||
if (!token) return res.status(401).end();
|
||||
|
||||
let userId;
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const fresh = _stmts.getUser.get(payload.id);
|
||||
if (req.query.ticket) {
|
||||
// Предпочтительный путь: одноразовый тикет (токен не светится в URL).
|
||||
userId = _consumeTicket(req.query.ticket);
|
||||
if (!userId) return res.status(401).end();
|
||||
const fresh = _stmts.getUser.get(userId);
|
||||
if (!fresh) return res.status(401).end();
|
||||
if (fresh.is_banned) return res.status(403).end();
|
||||
if (fresh.token_version != null && payload.tv !== fresh.token_version) return res.status(401).end();
|
||||
userId = payload.id;
|
||||
} catch {
|
||||
return res.status(401).end();
|
||||
} else {
|
||||
// Обратная совместимость: ?token=JWT (помечен на удаление после выката клиента).
|
||||
const token = req.query.token;
|
||||
if (!token) return res.status(401).end();
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const fresh = _stmts.getUser.get(payload.id);
|
||||
if (!fresh) return res.status(401).end();
|
||||
if (fresh.is_banned) return res.status(403).end();
|
||||
if (fresh.token_version != null && payload.tv !== fresh.token_version) return res.status(401).end();
|
||||
userId = payload.id;
|
||||
} catch {
|
||||
return res.status(401).end();
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
@@ -63,4 +93,4 @@ function stream(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { list, markRead, markAllRead, stream };
|
||||
module.exports = { list, markRead, markAllRead, stream, streamTicket };
|
||||
|
||||
Reference in New Issue
Block a user