feat(admin): журнал событий безопасности (Tier 1-2) + аудит чувствительных действий (Tier 3)
- security_events (миграция 047) + utils/securityLog.js (defensive, lazy stmt) - Tier 1: login.success/fail, register, password.change в authController - Tier 2: 403 (роль/разрешение) в middleware/auth, rate_limited в rateLimit - Tier 3: audit() на выдачу доступа (access), начисление/сброс XP (gam), модерацию аватаров - API GET/DELETE /api/admin/security-log (фильтр по категории + поиск, прунинг по дням) - Frontend: вкладка «Безопасность» в admin.html + loadSecurityLog, расширены ACTION_LABELS Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -606,6 +606,44 @@ function clearErrorLog(req, res) {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── GET /api/admin/security-log?limit&category&q ──────────────────────
|
||||||
|
Журнал событий безопасности/входа (security_events, миграция 047).
|
||||||
|
category ∈ auth | access_denied | rate_limit; q — поиск по email/ip/route/detail/event. */
|
||||||
|
function getSecurityLog(req, res) {
|
||||||
|
const limit = Math.min(1000, Math.max(1, Number(req.query.limit) || 200));
|
||||||
|
const category = req.query.category;
|
||||||
|
const q = (req.query.q || '').trim();
|
||||||
|
const where = [], args = [];
|
||||||
|
if (['auth', 'access_denied', 'rate_limit'].includes(category)) {
|
||||||
|
where.push('se.category = ?'); args.push(category);
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
where.push('(se.email LIKE ? OR se.ip LIKE ? OR se.route LIKE ? OR se.detail LIKE ? OR se.event LIKE ?)');
|
||||||
|
const like = `%${q}%`;
|
||||||
|
args.push(like, like, like, like, like);
|
||||||
|
}
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT se.*, u.name AS user_name, u.email AS user_email
|
||||||
|
FROM security_events se
|
||||||
|
LEFT JOIN users u ON u.id = se.user_id
|
||||||
|
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||||
|
ORDER BY se.created_at DESC LIMIT ?
|
||||||
|
`).all(...args, limit);
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DELETE /api/admin/security-log[?days=N] ───────────────────────────
|
||||||
|
Без days — очистить весь журнал; с days=N — прунинг записей старше N дней. */
|
||||||
|
function clearSecurityLog(req, res) {
|
||||||
|
const days = Number(req.query.days);
|
||||||
|
if (Number.isInteger(days) && days > 0) {
|
||||||
|
const r = db.prepare(`DELETE FROM security_events WHERE created_at < datetime('now', ?)`).run(`-${days} days`);
|
||||||
|
return res.json({ ok: true, deleted: r.changes });
|
||||||
|
}
|
||||||
|
const r = db.prepare('DELETE FROM security_events').run();
|
||||||
|
res.json({ ok: true, deleted: r.changes });
|
||||||
|
}
|
||||||
|
|
||||||
/* ── GET /api/admin/health ─────────────────────────────────────────── */
|
/* ── GET /api/admin/health ─────────────────────────────────────────── */
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -832,6 +870,7 @@ module.exports = {
|
|||||||
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
||||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||||
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
||||||
|
getSecurityLog, clearSecurityLog,
|
||||||
getTopics, createTopic, updateTopic, deleteTopic,
|
getTopics, createTopic, updateTopic, deleteTopic,
|
||||||
broadcast,
|
broadcast,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const jwt = require('jsonwebtoken');
|
|||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { BCRYPT_ROUNDS } = require('../config');
|
const { BCRYPT_ROUNDS } = require('../config');
|
||||||
const { stripTags } = require('../utils/sanitize');
|
const { stripTags } = require('../utils/sanitize');
|
||||||
|
const { logAuth } = require('../utils/securityLog');
|
||||||
|
|
||||||
function signToken(user) {
|
function signToken(user) {
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
@@ -38,6 +39,7 @@ async function register(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = db.prepare('SELECT id, email, name, role FROM users WHERE id = ?').get(lastInsertRowid);
|
const user = db.prepare('SELECT id, email, name, role FROM users WHERE id = ?').get(lastInsertRowid);
|
||||||
|
logAuth(req, 'register', { userId: user.id, email });
|
||||||
const token = signToken(user);
|
const token = signToken(user);
|
||||||
res.status(201).json({ token, user });
|
res.status(201).json({ token, user });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
@@ -54,10 +56,13 @@ async function login(req, res, next) {
|
|||||||
'SELECT id, email, name, role, password_hash, token_version, avatar_url FROM users WHERE email = ?'
|
'SELECT id, email, name, role, password_hash, token_version, avatar_url FROM users WHERE email = ?'
|
||||||
).get(email);
|
).get(email);
|
||||||
|
|
||||||
if (!user || !(await bcrypt.compare(password, user.password_hash)))
|
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||||
|
logAuth(req, 'login.fail', { userId: user?.id, email });
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?").run(user.id);
|
db.prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?").run(user.id);
|
||||||
|
logAuth(req, 'login.success', { userId: user.id, email: user.email });
|
||||||
|
|
||||||
const token = signToken(user);
|
const token = signToken(user);
|
||||||
res.json({ token, user: { id: user.id, email: user.email, name: user.name, role: user.role, avatar_url: user.avatar_url } });
|
res.json({ token, user: { id: user.id, email: user.email, name: user.name, role: user.role, avatar_url: user.avatar_url } });
|
||||||
@@ -87,6 +92,7 @@ async function updateProfile(req, res, next) {
|
|||||||
if (newPassword.length < 8) return res.status(400).json({ error: 'Пароль минимум 8 символов' });
|
if (newPassword.length < 8) return res.status(400).json({ error: 'Пароль минимум 8 символов' });
|
||||||
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||||
db.prepare('UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?').run(hash, user.id);
|
db.prepare('UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?').run(hash, user.id);
|
||||||
|
logAuth(req, 'password.change', { userId: user.id, email: user.email });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = db.prepare('SELECT id, email, name, role, created_at, token_version FROM users WHERE id = ?').get(user.id);
|
const updated = db.prepare('SELECT id, email, name, role, created_at, token_version FROM users WHERE id = ?').get(user.id);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
|
const { audit } = require('../utils/audit');
|
||||||
|
|
||||||
const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
|
const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ function approveAvatar(req, res) {
|
|||||||
WHERE id=?
|
WHERE id=?
|
||||||
`).run(req.user.id, row.id);
|
`).run(req.user.id, row.id);
|
||||||
|
|
||||||
|
audit(req, 'avatar.approve', `user:${row.user_id}`, row.filename);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ function rejectAvatar(req, res) {
|
|||||||
WHERE id=?
|
WHERE id=?
|
||||||
`).run(req.user.id, msg || null, row.id);
|
`).run(req.user.id, msg || null, row.id);
|
||||||
|
|
||||||
|
audit(req, 'avatar.reject', `user:${row.user_id}`, msg || '');
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
const db = require('../../db/db');
|
const db = require('../../db/db');
|
||||||
const { stmts } = require('./_shared');
|
const { stmts } = require('./_shared');
|
||||||
const { awardXP, awardCoins } = require('./service');
|
const { awardXP, awardCoins } = require('./service');
|
||||||
|
const { audit } = require('../../utils/audit');
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
Gamification — admin handlers
|
Gamification — admin handlers
|
||||||
@@ -22,6 +23,9 @@ function adminAward(req, res) {
|
|||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
if (xpNum > 0) awardXP(userId, xpNum, reason || 'Admin award');
|
if (xpNum > 0) awardXP(userId, xpNum, reason || 'Admin award');
|
||||||
if (coinsNum > 0) awardCoins(userId, coinsNum, reason || 'Admin award');
|
if (coinsNum > 0) awardCoins(userId, coinsNum, reason || 'Admin award');
|
||||||
|
if (xpNum > 0 || coinsNum > 0) {
|
||||||
|
audit(req, 'gam.award', `user:${userId}`, `+${xpNum} XP, +${coinsNum} монет — ${reason || 'Admin award'}`);
|
||||||
|
}
|
||||||
const updated = stmts.getUserGamInfo.get(userId);
|
const updated = stmts.getUserGamInfo.get(userId);
|
||||||
res.json({ ok: true, ...updated });
|
res.json({ ok: true, ...updated });
|
||||||
}
|
}
|
||||||
@@ -40,6 +44,7 @@ function adminReset(req, res) {
|
|||||||
const { userId } = req.body;
|
const { userId } = req.body;
|
||||||
if (!userId) return res.status(400).json({ error: 'userId required' });
|
if (!userId) return res.status(400).json({ error: 'userId required' });
|
||||||
_resetTx(userId);
|
_resetTx(userId);
|
||||||
|
audit(req, 'gam.reset', `user:${userId}`, 'сброс XP/монет/ачивок/истории');
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- 047_security_events.sql
|
||||||
|
-- Журнал событий безопасности и входа (Tier 1 + Tier 2 плана логирования).
|
||||||
|
--
|
||||||
|
-- Отдельная таблица, НЕ admin_audit_log, потому что:
|
||||||
|
-- • автор часто аноним (у неудачного логина ещё нет user_id);
|
||||||
|
-- • объём может быть большим (боты долбят логин / rate-limit);
|
||||||
|
-- • свой ретеншн (чистится агрессивнее, чем осознанные админ-действия).
|
||||||
|
-- admin_audit_log остаётся домом для привилегированных действий (Tier 3).
|
||||||
|
--
|
||||||
|
-- category:
|
||||||
|
-- auth — login.success | login.fail | register | password.change
|
||||||
|
-- access_denied — forbidden (роль) | perm_denied (разрешение)
|
||||||
|
-- rate_limit — rate_limited (превышение лимита запросов)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS security_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
category TEXT NOT NULL CHECK (category IN ('auth','access_denied','rate_limit')),
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
email TEXT, -- email из попытки/связанный (для анонимных)
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
method TEXT,
|
||||||
|
route TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sec_events_date ON security_events (created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sec_events_category ON security_events (category, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sec_events_email ON security_events (email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sec_events_ip ON security_events (ip);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sec_events_user ON security_events (user_id);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const registry = require('../permissions/registry');
|
const registry = require('../permissions/registry');
|
||||||
|
const { logDenied } = require('../utils/securityLog');
|
||||||
|
|
||||||
/* ── Default values for role_permissions — sourced from central registry ── */
|
/* ── Default values for role_permissions — sourced from central registry ── */
|
||||||
const PERM_DEFAULTS = registry.buildDefaultsMap();
|
const PERM_DEFAULTS = registry.buildDefaultsMap();
|
||||||
@@ -34,6 +35,7 @@ function authMiddleware(req, res, next) {
|
|||||||
function requireRole(...roles) {
|
function requireRole(...roles) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (!roles.includes(req.user?.role)) {
|
if (!roles.includes(req.user?.role)) {
|
||||||
|
if (req.user) logDenied(req, 'forbidden', `роль ${req.user.role}; требуется ${roles.join('/')}`);
|
||||||
return res.status(403).json({ error: 'Forbidden' });
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
@@ -53,7 +55,9 @@ function requirePermission(key) {
|
|||||||
'SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?'
|
'SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?'
|
||||||
).get(uid, key);
|
).get(uid, key);
|
||||||
if (userRow !== undefined) {
|
if (userRow !== undefined) {
|
||||||
return userRow.enabled === 1 ? next() : res.status(403).json({ error: 'Permission denied' });
|
if (userRow.enabled === 1) return next();
|
||||||
|
logDenied(req, 'perm_denied', key);
|
||||||
|
return res.status(403).json({ error: 'Permission denied' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Role-level
|
// 2. Role-level
|
||||||
@@ -62,6 +66,7 @@ function requirePermission(key) {
|
|||||||
).get(role, key);
|
).get(role, key);
|
||||||
const enabled = roleRow !== undefined ? roleRow.enabled === 1 : (PERM_DEFAULTS[role]?.[key] ?? false);
|
const enabled = roleRow !== undefined ? roleRow.enabled === 1 : (PERM_DEFAULTS[role]?.[key] ?? false);
|
||||||
if (enabled) return next();
|
if (enabled) return next();
|
||||||
|
logDenied(req, 'perm_denied', key);
|
||||||
return res.status(403).json({ error: 'Permission denied' });
|
return res.status(403).json({ error: 'Permission denied' });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/* Simple in-memory rate limiter — no external dependency needed */
|
/* Simple in-memory rate limiter — no external dependency needed */
|
||||||
|
const { logRateLimit } = require('../utils/securityLog');
|
||||||
|
|
||||||
// Clean stale entries every 5 minutes across all stores
|
// Clean stale entries every 5 minutes across all stores
|
||||||
const _allStores = new Set();
|
const _allStores = new Set();
|
||||||
@@ -35,6 +36,8 @@ module.exports = function rateLimit({ windowMs = 60_000, max = 10, message = 'To
|
|||||||
store.set(key, entry);
|
store.set(key, entry);
|
||||||
|
|
||||||
if (entry.count > max) {
|
if (entry.count > max) {
|
||||||
|
// Log once per window (on first crossing) to avoid spamming the security log.
|
||||||
|
if (entry.count === max + 1) logRateLimit(req, `лимит ${max}/${Math.round(windowMs / 1000)}с`);
|
||||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
res.set('Retry-After', retryAfter);
|
res.set('Retry-After', retryAfter);
|
||||||
return res.status(429).json({ error: message });
|
return res.status(429).json({ error: message });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
|
const { audit } = require('../utils/audit');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
router.use(requireRole('admin', 'teacher'));
|
router.use(requireRole('admin', 'teacher'));
|
||||||
@@ -198,6 +199,7 @@ router.post('/rules', (req, res) => {
|
|||||||
db.prepare(`DELETE FROM content_access
|
db.prepare(`DELETE FROM content_access
|
||||||
WHERE content_type = ? AND content_ref = ? AND scope = ? AND target_id = ?`)
|
WHERE content_type = ? AND content_ref = ? AND scope = ? AND target_id = ?`)
|
||||||
.run(content_type, content_ref, scope, tid);
|
.run(content_type, content_ref, scope, tid);
|
||||||
|
audit(req, 'access.inherit', `${content_type}:${content_ref}`, `${scope}:${tid}`);
|
||||||
return res.json({ ok: true, allow: null });
|
return res.json({ ok: true, allow: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +211,7 @@ router.post('/rules', (req, res) => {
|
|||||||
DO UPDATE SET allow = excluded.allow, created_by = excluded.created_by, created_at = datetime('now')
|
DO UPDATE SET allow = excluded.allow, created_by = excluded.created_by, created_at = datetime('now')
|
||||||
`).run(content_type, content_ref, scope, tid, allow, req.user.id);
|
`).run(content_type, content_ref, scope, tid, allow, req.user.id);
|
||||||
|
|
||||||
|
audit(req, allow ? 'access.grant' : 'access.deny', `${content_type}:${content_ref}`, `${scope}:${tid}`);
|
||||||
res.json({ ok: true, allow });
|
res.json({ ok: true, allow });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ router.delete('/audit-log', ctrl.clearAuditLog);
|
|||||||
router.get('/error-log', ctrl.getErrorLog);
|
router.get('/error-log', ctrl.getErrorLog);
|
||||||
router.delete('/error-log', ctrl.clearErrorLog);
|
router.delete('/error-log', ctrl.clearErrorLog);
|
||||||
|
|
||||||
|
/* Security / auth event log */
|
||||||
|
router.get('/security-log', ctrl.getSecurityLog);
|
||||||
|
router.delete('/security-log', ctrl.clearSecurityLog);
|
||||||
|
|
||||||
/* System health */
|
/* System health */
|
||||||
router.get('/health', ctrl.getHealth);
|
router.get('/health', ctrl.getHealth);
|
||||||
router.get('/metrics', ctrl.getMetrics);
|
router.get('/metrics', ctrl.getMetrics);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use strict';
|
||||||
|
/* Security / auth event logger → security_events (миграция 047).
|
||||||
|
*
|
||||||
|
* Парная утилита к utils/audit.js:
|
||||||
|
* • audit() — осознанные привилегированные действия (Tier 3) в admin_audit_log
|
||||||
|
* • logSecurity() — события безопасности/входа (Tier 1+2): аноним, большой объём, свой ретеншн
|
||||||
|
*
|
||||||
|
* Логирование НИКОГДА не должно ронять запрос — всё в try/catch.
|
||||||
|
* Statement готовится лениво: server.js запускается отдельно от миграций,
|
||||||
|
* поэтому таблицы может ещё не быть на момент require().
|
||||||
|
*/
|
||||||
|
const db = require('../db/db');
|
||||||
|
|
||||||
|
let _stmt = null;
|
||||||
|
function stmt() {
|
||||||
|
if (!_stmt) {
|
||||||
|
_stmt = db.prepare(
|
||||||
|
`INSERT INTO security_events
|
||||||
|
(category, event, user_id, email, ip, user_agent, method, route, detail)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ip(req) { return (req && (req.ip || req.socket?.remoteAddress)) || ''; }
|
||||||
|
function _ua(req) { const ua = req?.headers?.['user-agent']; return ua ? String(ua).slice(0, 300) : ''; }
|
||||||
|
function _route(req) { return String(req?.originalUrl || req?.url || '').split('?')[0]; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Записать событие безопасности.
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {object} [opts.req] Express request (для IP/UA/route/user)
|
||||||
|
* @param {string} opts.category 'auth' | 'access_denied' | 'rate_limit'
|
||||||
|
* @param {string} opts.event 'login.fail' | 'forbidden' | 'rate_limited' | ...
|
||||||
|
* @param {number} [opts.userId] явный id (иначе берётся req.user.id)
|
||||||
|
* @param {string} [opts.email] email из попытки/связанный
|
||||||
|
* @param {string} [opts.detail] человекочитаемая деталь
|
||||||
|
*/
|
||||||
|
function logSecurity({ req, category, event, userId, email, detail } = {}) {
|
||||||
|
try {
|
||||||
|
const uid = userId != null ? userId : (req?.user?.id ?? null);
|
||||||
|
stmt().run(
|
||||||
|
category, event, uid, email || null,
|
||||||
|
_ip(req), _ua(req), req?.method || null, _route(req), detail || null
|
||||||
|
);
|
||||||
|
} catch (e) { console.error('[securityLog]', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Эргономичные хелперы */
|
||||||
|
const logAuth = (req, event, extra = {}) => logSecurity({ req, category: 'auth', event, ...extra });
|
||||||
|
const logDenied = (req, event, detail) => logSecurity({ req, category: 'access_denied', event, detail });
|
||||||
|
const logRateLimit = (req, detail) => logSecurity({ req, category: 'rate_limit', event: 'rate_limited', detail });
|
||||||
|
|
||||||
|
module.exports = { logSecurity, logAuth, logDenied, logRateLimit };
|
||||||
@@ -1076,6 +1076,9 @@
|
|||||||
<button class="admin-nav-item" data-tab="audit" onclick="switchTab(this)">
|
<button class="admin-nav-item" data-tab="audit" onclick="switchTab(this)">
|
||||||
<i data-lucide="scroll-text" style="width:15px;height:15px"></i> Аудит-лог
|
<i data-lucide="scroll-text" style="width:15px;height:15px"></i> Аудит-лог
|
||||||
</button>
|
</button>
|
||||||
|
<button class="admin-nav-item" data-tab="security" onclick="switchTab(this)">
|
||||||
|
<i data-lucide="shield-alert" style="width:15px;height:15px"></i> Безопасность
|
||||||
|
</button>
|
||||||
<button class="admin-nav-item" data-tab="errors" onclick="switchTab(this)">
|
<button class="admin-nav-item" data-tab="errors" onclick="switchTab(this)">
|
||||||
<i data-lucide="bug" style="width:15px;height:15px"></i> Ошибки
|
<i data-lucide="bug" style="width:15px;height:15px"></i> Ошибки
|
||||||
</button>
|
</button>
|
||||||
@@ -1680,6 +1683,29 @@
|
|||||||
<div id="audit-list"></div>
|
<div id="audit-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Безопасность / журнал событий ── -->
|
||||||
|
<div class="tab-pane" id="tab-security">
|
||||||
|
<div class="section-title" style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||||||
|
Журнал событий безопасности
|
||||||
|
<button class="adm-btn adm-btn-danger adm-btn-small" onclick="clearSecurityLog()">Очистить</button>
|
||||||
|
</div>
|
||||||
|
<p class="perm-desc" style="margin:-8px 0 16px;max-width:760px">
|
||||||
|
Входы и неудачные попытки, отказы доступа (роль/разрешение) и превышения лимита запросов.
|
||||||
|
Записываются IP и e-mail попытки — даже для неавторизованных.
|
||||||
|
</p>
|
||||||
|
<div class="sl-filter-row">
|
||||||
|
<select class="sl-filter-select" id="sec-cat-filter" onchange="loadSecurityLog()">
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
<option value="auth">Вход / аккаунт</option>
|
||||||
|
<option value="access_denied">Отказы доступа</option>
|
||||||
|
<option value="rate_limit">Превышение лимита</option>
|
||||||
|
</select>
|
||||||
|
<input class="t-input" id="sec-search" type="text" placeholder="Поиск: email, IP, маршрут…" oninput="secSearchDebounce()" style="min-width:220px" />
|
||||||
|
<span class="sl-count" id="sec-count"></span>
|
||||||
|
</div>
|
||||||
|
<div id="security-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Ошибки ── -->
|
<!-- ── Ошибки ── -->
|
||||||
<div class="tab-pane" id="tab-errors">
|
<div class="tab-pane" id="tab-errors">
|
||||||
<div class="section-title" style="display:flex;align-items:center;justify-content:space-between">
|
<div class="section-title" style="display:flex;align-items:center;justify-content:space-between">
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
// System tabs (not yet extracted in Phase 2) — load inline
|
// System tabs (not yet extracted in Phase 2) — load inline
|
||||||
if (name === 'topics') loadTopics();
|
if (name === 'topics') loadTopics();
|
||||||
else if (name === 'audit') loadAuditLog();
|
else if (name === 'audit') loadAuditLog();
|
||||||
|
else if (name === 'security') loadSecurityLog();
|
||||||
else if (name === 'errors') loadErrorLog();
|
else if (name === 'errors') loadErrorLog();
|
||||||
else if (name === 'health') loadHealth();
|
else if (name === 'health') loadHealth();
|
||||||
else if (name === 'classroom'){ loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
|
else if (name === 'classroom'){ loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
|
||||||
@@ -235,9 +236,14 @@
|
|||||||
'user.unban': 'Разблокировка', 'user.delete': 'Удаление', 'user.clear_sessions': 'Очистка истории',
|
'user.unban': 'Разблокировка', 'user.delete': 'Удаление', 'user.clear_sessions': 'Очистка истории',
|
||||||
'features.update': 'Фичи обновлены', 'topic.create': 'Создание темы', 'topic.update': 'Редакт. темы',
|
'features.update': 'Фичи обновлены', 'topic.create': 'Создание темы', 'topic.update': 'Редакт. темы',
|
||||||
'topic.delete': 'Удаление темы', 'broadcast': 'Рассылка',
|
'topic.delete': 'Удаление темы', 'broadcast': 'Рассылка',
|
||||||
|
'access.grant': 'Доступ открыт', 'access.deny': 'Доступ закрыт', 'access.inherit': 'Доступ сброшен',
|
||||||
|
'gam.award': 'Начисление XP/монет', 'gam.reset': 'Сброс прогресса',
|
||||||
|
'avatar.approve': 'Аватар одобрен', 'avatar.reject': 'Аватар отклонён',
|
||||||
};
|
};
|
||||||
const ACTION_COLORS = {
|
const ACTION_COLORS = {
|
||||||
'user.delete': 'var(--pink)', 'user.ban': 'var(--pink)', 'user.clear_sessions': 'var(--amber)',
|
'user.delete': 'var(--pink)', 'user.ban': 'var(--pink)', 'user.clear_sessions': 'var(--amber)',
|
||||||
|
'gam.reset': 'var(--pink)', 'avatar.reject': 'var(--amber)', 'access.deny': 'var(--amber)',
|
||||||
|
'access.grant': 'var(--green)', 'gam.award': 'var(--green)', 'avatar.approve': 'var(--green)',
|
||||||
};
|
};
|
||||||
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
|
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
|
||||||
<thead><tr><th>Дата</th><th>Админ</th><th>Действие</th><th>Цель</th><th>Детали</th><th>IP</th></tr></thead>
|
<thead><tr><th>Дата</th><th>Админ</th><th>Действие</th><th>Цель</th><th>Детали</th><th>IP</th></tr></thead>
|
||||||
@@ -298,6 +304,78 @@
|
|||||||
}
|
}
|
||||||
window.clearErrorLog = clearErrorLog;
|
window.clearErrorLog = clearErrorLog;
|
||||||
|
|
||||||
|
/* ═══ SECURITY EVENT LOG ═══════════════════════════════════════════ */
|
||||||
|
let _secDebTimer = null;
|
||||||
|
function secSearchDebounce() {
|
||||||
|
clearTimeout(_secDebTimer);
|
||||||
|
_secDebTimer = setTimeout(loadSecurityLog, 300);
|
||||||
|
}
|
||||||
|
window.secSearchDebounce = secSearchDebounce;
|
||||||
|
|
||||||
|
const SEC_EVENT_LABELS = {
|
||||||
|
'login.success': 'Успешный вход',
|
||||||
|
'login.fail': 'Неудачный вход',
|
||||||
|
'register': 'Регистрация',
|
||||||
|
'password.change': 'Смена пароля',
|
||||||
|
'forbidden': 'Отказ по роли',
|
||||||
|
'perm_denied': 'Нет разрешения',
|
||||||
|
'rate_limited': 'Превышен лимит',
|
||||||
|
};
|
||||||
|
const SEC_CAT_LABELS = { auth: 'Вход', access_denied: 'Доступ', rate_limit: 'Лимит' };
|
||||||
|
// Цвет по тяжести: успех/регистрация — зелёный, лимит — янтарь, остальное (фейл/отказ) — розовый.
|
||||||
|
function secColor(ev, cat) {
|
||||||
|
if (ev === 'login.success' || ev === 'register') return 'var(--green)';
|
||||||
|
if (cat === 'rate_limit') return 'var(--amber)';
|
||||||
|
return 'var(--pink)';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecurityLog() {
|
||||||
|
const el = document.getElementById('security-list');
|
||||||
|
const countEl = document.getElementById('sec-count');
|
||||||
|
el.innerHTML = LS.skeleton(5, 'row');
|
||||||
|
try {
|
||||||
|
const cat = document.getElementById('sec-cat-filter')?.value || '';
|
||||||
|
const q = (document.getElementById('sec-search')?.value || '').trim();
|
||||||
|
const params = new URLSearchParams({ limit: 300 });
|
||||||
|
if (cat) params.set('category', cat);
|
||||||
|
if (q) params.set('q', q);
|
||||||
|
const rows = await LS.api('/api/admin/security-log?' + params);
|
||||||
|
if (countEl) countEl.textContent = rows.length ? `${rows.length} событий` : '';
|
||||||
|
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Событий нет</div>'; return; }
|
||||||
|
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
|
||||||
|
<thead><tr><th>Время</th><th>Категория</th><th>Событие</th><th>Пользователь / email</th><th>IP</th><th>Маршрут</th><th>Детали</th></tr></thead>
|
||||||
|
<tbody>${rows.map(r => {
|
||||||
|
const raw = r.created_at || '';
|
||||||
|
const dt = new Date(raw.includes('T') ? raw : raw.replace(' ', 'T') + 'Z');
|
||||||
|
const ds = isNaN(dt) ? esc(raw) : dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
|
||||||
|
const col = secColor(r.event, r.category);
|
||||||
|
const who = r.user_name ? esc(r.user_name) : (r.email ? esc(r.email) : (r.user_email ? esc(r.user_email) : '—'));
|
||||||
|
const sub = (r.user_name && r.email && r.email !== r.user_email) ? `<div style="font-size:.72rem;color:var(--text-3)">${esc(r.email)}</div>` : '';
|
||||||
|
return `<tr>
|
||||||
|
<td><span class="sl-date">${ds}</span></td>
|
||||||
|
<td><span class="sl-role-badge" style="background:rgba(155,93,229,.08);color:var(--violet)">${SEC_CAT_LABELS[r.category]||r.category}</span></td>
|
||||||
|
<td><span style="color:${col};font-weight:700;font-size:.82rem">${SEC_EVENT_LABELS[r.event]||r.event}</span></td>
|
||||||
|
<td>${who}${sub}</td>
|
||||||
|
<td style="font-size:.78rem;color:var(--text-3);font-family:monospace">${esc(r.ip||'')}</td>
|
||||||
|
<td style="font-size:.78rem;color:var(--text-3);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc((r.method||'')+' '+(r.route||''))}">${esc(r.route||'')}</td>
|
||||||
|
<td style="font-size:.8rem;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.detail||'')}">${esc(r.detail||'')}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('')}</tbody></table></div>`;
|
||||||
|
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
|
||||||
|
}
|
||||||
|
window.loadSecurityLog = loadSecurityLog;
|
||||||
|
|
||||||
|
async function clearSecurityLog() {
|
||||||
|
if (!await LS.confirm('Очистить журнал событий безопасности?', { danger: true })) return;
|
||||||
|
try {
|
||||||
|
await LS.api('/api/admin/security-log', { method:'DELETE' });
|
||||||
|
document.getElementById('security-list').innerHTML = '<div style="padding:32px;text-align:center;color:var(--text-3)">Журнал очищен</div>';
|
||||||
|
const c = document.getElementById('sec-count'); if (c) c.textContent = '';
|
||||||
|
LS.toast('Журнал очищен', 'success');
|
||||||
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
window.clearSecurityLog = clearSecurityLog;
|
||||||
|
|
||||||
/* ═══ SYSTEM HEALTH ════════════════════════════════════════════════ */
|
/* ═══ SYSTEM HEALTH ════════════════════════════════════════════════ */
|
||||||
let _healthLive = false, _healthTimer = null;
|
let _healthLive = false, _healthTimer = null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user