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 db = require('../db/db');
|
||||||
const { addClient, removeClient } = require('../sse');
|
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 = {
|
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'),
|
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 = ?'),
|
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) ─────────── */
|
/* ── GET /api/notifications/stream ── SSE (auth via ?token=JWT) ─────────── */
|
||||||
function stream(req, res) {
|
function stream(req, res) {
|
||||||
const token = req.query.token;
|
|
||||||
if (!token) return res.status(401).end();
|
|
||||||
|
|
||||||
let userId;
|
let userId;
|
||||||
try {
|
if (req.query.ticket) {
|
||||||
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
// Предпочтительный путь: одноразовый тикет (токен не светится в URL).
|
||||||
const fresh = _stmts.getUser.get(payload.id);
|
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) return res.status(401).end();
|
||||||
if (fresh.is_banned) return res.status(403).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();
|
} else {
|
||||||
userId = payload.id;
|
// Обратная совместимость: ?token=JWT (помечен на удаление после выката клиента).
|
||||||
} catch {
|
const token = req.query.token;
|
||||||
return res.status(401).end();
|
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');
|
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 };
|
||||||
|
|||||||
@@ -3,16 +3,21 @@
|
|||||||
* 'pet' навешивается при монтировании в server.js. */
|
* 'pet' навешивается при монтировании в server.js. */
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
|
const rateLimit = require('../middleware/rateLimit');
|
||||||
const ctrl = require('../controllers/assistantController');
|
const ctrl = require('../controllers/assistantController');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// Дорогие LLM-вызовы — пер-юзер лимит (защита от «сжигания» бюджета провайдера).
|
||||||
|
const askLimiter = rateLimit({ windowMs: 60_000, max: 20, byUser: true, message: 'Слишком много запросов к ИИ — подожди минутку' });
|
||||||
|
const fcLimiter = rateLimit({ windowMs: 60_000, max: 10, byUser: true, message: 'Слишком часто — подожди минутку' });
|
||||||
|
|
||||||
router.get('/context', ctrl.getContext);
|
router.get('/context', ctrl.getContext);
|
||||||
router.post('/seen', ctrl.markSeen);
|
router.post('/seen', ctrl.markSeen);
|
||||||
router.post('/dismiss', ctrl.dismiss);
|
router.post('/dismiss', ctrl.dismiss);
|
||||||
router.patch('/settings', ctrl.setSettings);
|
router.patch('/settings', ctrl.setSettings);
|
||||||
router.post('/ask', ctrl.ask);
|
router.post('/ask', askLimiter, ctrl.ask);
|
||||||
router.post('/flashcards', ctrl.flashcardsFromText);
|
router.post('/flashcards', fcLimiter, ctrl.flashcardsFromText);
|
||||||
router.post('/feedback', ctrl.feedback);
|
router.post('/feedback', ctrl.feedback);
|
||||||
router.get('/memory', ctrl.getMemory);
|
router.get('/memory', ctrl.getMemory);
|
||||||
router.delete('/memory', ctrl.clearMemory);
|
router.delete('/memory', ctrl.clearMemory);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
|
const rateLimit = require('../middleware/rateLimit');
|
||||||
const ctrl = require('../controllers/imggenController');
|
const ctrl = require('../controllers/imggenController');
|
||||||
|
|
||||||
|
// Пер-юзер лимит поверх cooldown/дневного лимита в контроллере (защита от абуза).
|
||||||
|
const genLimiter = rateLimit({ windowMs: 60_000, max: 20, byUser: true, message: 'Слишком много генераций — подожди минутку' });
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
router.get('/status', ctrl.status);
|
router.get('/status', ctrl.status);
|
||||||
router.post('/', ctrl.generate);
|
router.post('/', genLimiter, ctrl.generate);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const ctrl = require('../controllers/notificationController');
|
|||||||
router.get('/stream', ctrl.stream);
|
router.get('/stream', ctrl.stream);
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
router.get('/stream-ticket', ctrl.streamTicket);
|
||||||
router.get('/', ctrl.list);
|
router.get('/', ctrl.list);
|
||||||
router.post('/read-all', ctrl.markAllRead);
|
router.post('/read-all', ctrl.markAllRead);
|
||||||
router.patch('/:id/read', ctrl.markRead);
|
router.patch('/:id/read', ctrl.markRead);
|
||||||
|
|||||||
@@ -1386,14 +1386,28 @@ async function markAllNotifsRead() { return req('POST', '/notifications/read-al
|
|||||||
/* ── SSE real-time notifications ─────────────────────────────────────────── */
|
/* ── SSE real-time notifications ─────────────────────────────────────────── */
|
||||||
/* ── Shared SSE singleton — all listeners share one EventSource ─────── */
|
/* ── Shared SSE singleton — all listeners share one EventSource ─────── */
|
||||||
let _sseShared = null;
|
let _sseShared = null;
|
||||||
|
let _sseConnecting = false;
|
||||||
let _sseRetryMs = 2000;
|
let _sseRetryMs = 2000;
|
||||||
let _sseEverConnected = false; // tracks whether SSE has successfully opened before
|
let _sseEverConnected = false; // tracks whether SSE has successfully opened before
|
||||||
const _sseListeners = new Set();
|
const _sseListeners = new Set();
|
||||||
|
|
||||||
function _sseConnect() {
|
function _sseRetry() {
|
||||||
const token = getToken();
|
const delay = Math.min(_sseRetryMs, 30000);
|
||||||
if (!token) return;
|
_sseRetryMs = Math.min(_sseRetryMs * 2, 30000);
|
||||||
const url = `${API}/notifications/stream?token=${encodeURIComponent(token)}`;
|
setTimeout(_sseConnect, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _sseConnect() {
|
||||||
|
if (_sseShared || _sseConnecting) return;
|
||||||
|
if (!getToken()) return;
|
||||||
|
// Берём одноразовый тикет authed-запросом (Bearer в заголовке) — токен не попадает в URL.
|
||||||
|
_sseConnecting = true;
|
||||||
|
let ticket = null;
|
||||||
|
try { const r = await req('GET', '/notifications/stream-ticket'); ticket = r && r.ticket; } catch (e) {}
|
||||||
|
_sseConnecting = false;
|
||||||
|
if (_sseShared) return; // подключились параллельно
|
||||||
|
if (!ticket) { _sseRetry(); return; } // не вышло — повтор по backoff
|
||||||
|
const url = `${API}/notifications/stream?ticket=${encodeURIComponent(ticket)}`;
|
||||||
const es = new EventSource(url);
|
const es = new EventSource(url);
|
||||||
_sseShared = es;
|
_sseShared = es;
|
||||||
es.onopen = () => {
|
es.onopen = () => {
|
||||||
@@ -1412,9 +1426,7 @@ function _sseConnect() {
|
|||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
es.close();
|
es.close();
|
||||||
_sseShared = null;
|
_sseShared = null;
|
||||||
const delay = Math.min(_sseRetryMs, 30000);
|
_sseRetry();
|
||||||
_sseRetryMs = Math.min(_sseRetryMs * 2, 30000);
|
|
||||||
setTimeout(_sseConnect, delay);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user