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 });
|
||||
}
|
||||
|
||||
/* ── 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 ─────────────────────────────────────────── */
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
@@ -832,6 +870,7 @@ module.exports = {
|
||||
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
||||
getSecurityLog, clearSecurityLog,
|
||||
getTopics, createTopic, updateTopic, deleteTopic,
|
||||
broadcast,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ const jwt = require('jsonwebtoken');
|
||||
const db = require('../db/db');
|
||||
const { BCRYPT_ROUNDS } = require('../config');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { logAuth } = require('../utils/securityLog');
|
||||
|
||||
function signToken(user) {
|
||||
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);
|
||||
logAuth(req, 'register', { userId: user.id, email });
|
||||
const token = signToken(user);
|
||||
res.status(201).json({ token, user });
|
||||
} 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 = ?'
|
||||
).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' });
|
||||
}
|
||||
|
||||
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);
|
||||
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 символов' });
|
||||
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);
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const db = require('../db/db');
|
||||
const { audit } = require('../utils/audit');
|
||||
|
||||
const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
|
||||
|
||||
@@ -71,6 +72,7 @@ function approveAvatar(req, res) {
|
||||
WHERE id=?
|
||||
`).run(req.user.id, row.id);
|
||||
|
||||
audit(req, 'avatar.approve', `user:${row.user_id}`, row.filename);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -91,6 +93,7 @@ function rejectAvatar(req, res) {
|
||||
WHERE id=?
|
||||
`).run(req.user.id, msg || null, row.id);
|
||||
|
||||
audit(req, 'avatar.reject', `user:${row.user_id}`, msg || '');
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const db = require('../../db/db');
|
||||
const { stmts } = require('./_shared');
|
||||
const { awardXP, awardCoins } = require('./service');
|
||||
const { audit } = require('../../utils/audit');
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
Gamification — admin handlers
|
||||
@@ -22,6 +23,9 @@ function adminAward(req, res) {
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (xpNum > 0) awardXP(userId, xpNum, 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);
|
||||
res.json({ ok: true, ...updated });
|
||||
}
|
||||
@@ -40,6 +44,7 @@ function adminReset(req, res) {
|
||||
const { userId } = req.body;
|
||||
if (!userId) return res.status(400).json({ error: 'userId required' });
|
||||
_resetTx(userId);
|
||||
audit(req, 'gam.reset', `user:${userId}`, 'сброс XP/монет/ачивок/истории');
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user