diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 76a235f..fb63689 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -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, }; diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index 91d4c10..8bd86c9 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -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); diff --git a/backend/src/controllers/avatarController.js b/backend/src/controllers/avatarController.js index 602d254..fc5aa36 100644 --- a/backend/src/controllers/avatarController.js +++ b/backend/src/controllers/avatarController.js @@ -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 }); } diff --git a/backend/src/controllers/gamification/admin.js b/backend/src/controllers/gamification/admin.js index 0ec6b7b..44b1071 100644 --- a/backend/src/controllers/gamification/admin.js +++ b/backend/src/controllers/gamification/admin.js @@ -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 }); } diff --git a/backend/src/db/migrations/047_security_events.sql b/backend/src/db/migrations/047_security_events.sql new file mode 100644 index 0000000..59099ab --- /dev/null +++ b/backend/src/db/migrations/047_security_events.sql @@ -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); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 6395ccd..70628f5 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -1,6 +1,7 @@ const jwt = require('jsonwebtoken'); const db = require('../db/db'); const registry = require('../permissions/registry'); +const { logDenied } = require('../utils/securityLog'); /* ── Default values for role_permissions — sourced from central registry ── */ const PERM_DEFAULTS = registry.buildDefaultsMap(); @@ -34,6 +35,7 @@ function authMiddleware(req, res, next) { function requireRole(...roles) { return (req, res, next) => { if (!roles.includes(req.user?.role)) { + if (req.user) logDenied(req, 'forbidden', `роль ${req.user.role}; требуется ${roles.join('/')}`); return res.status(403).json({ error: 'Forbidden' }); } next(); @@ -53,7 +55,9 @@ function requirePermission(key) { 'SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?' ).get(uid, key); 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 @@ -62,6 +66,7 @@ function requirePermission(key) { ).get(role, key); const enabled = roleRow !== undefined ? roleRow.enabled === 1 : (PERM_DEFAULTS[role]?.[key] ?? false); if (enabled) return next(); + logDenied(req, 'perm_denied', key); return res.status(403).json({ error: 'Permission denied' }); }; } diff --git a/backend/src/middleware/rateLimit.js b/backend/src/middleware/rateLimit.js index 2c65672..86a958a 100644 --- a/backend/src/middleware/rateLimit.js +++ b/backend/src/middleware/rateLimit.js @@ -1,4 +1,5 @@ /* Simple in-memory rate limiter — no external dependency needed */ +const { logRateLimit } = require('../utils/securityLog'); // Clean stale entries every 5 minutes across all stores const _allStores = new Set(); @@ -35,6 +36,8 @@ module.exports = function rateLimit({ windowMs = 60_000, max = 10, message = 'To store.set(key, entry); 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); res.set('Retry-After', retryAfter); return res.status(429).json({ error: message }); diff --git a/backend/src/routes/access.js b/backend/src/routes/access.js index 53279ed..f59bfcc 100644 --- a/backend/src/routes/access.js +++ b/backend/src/routes/access.js @@ -7,6 +7,7 @@ const router = require('express').Router(); const db = require('../db/db'); const { authMiddleware, requireRole } = require('../middleware/auth'); +const { audit } = require('../utils/audit'); router.use(authMiddleware); router.use(requireRole('admin', 'teacher')); @@ -198,6 +199,7 @@ router.post('/rules', (req, res) => { db.prepare(`DELETE FROM content_access WHERE content_type = ? AND content_ref = ? AND scope = ? AND target_id = ?`) .run(content_type, content_ref, scope, tid); + audit(req, 'access.inherit', `${content_type}:${content_ref}`, `${scope}:${tid}`); 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') `).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 }); }); diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index b78d83c..4fd525f 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -36,6 +36,10 @@ router.delete('/audit-log', ctrl.clearAuditLog); router.get('/error-log', ctrl.getErrorLog); router.delete('/error-log', ctrl.clearErrorLog); +/* Security / auth event log */ +router.get('/security-log', ctrl.getSecurityLog); +router.delete('/security-log', ctrl.clearSecurityLog); + /* System health */ router.get('/health', ctrl.getHealth); router.get('/metrics', ctrl.getMetrics); diff --git a/backend/src/utils/securityLog.js b/backend/src/utils/securityLog.js new file mode 100644 index 0000000..c63efe2 --- /dev/null +++ b/backend/src/utils/securityLog.js @@ -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 }; diff --git a/frontend/admin.html b/frontend/admin.html index 02af9fc..0a1f114 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -1076,6 +1076,9 @@ + @@ -1680,6 +1683,29 @@
+ ++ Входы и неудачные попытки, отказы доступа (роль/разрешение) и превышения лимита запросов. + Записываются IP и e-mail попытки — даже для неавторизованных. +
+| Дата | Админ | Действие | Цель | Детали | IP |
|---|
| Время | Категория | Событие | Пользователь / email | IP | Маршрут | Детали |
|---|---|---|---|---|---|---|
| ${ds} | +${SEC_CAT_LABELS[r.category]||r.category} | +${SEC_EVENT_LABELS[r.event]||r.event} | +${who}${sub} | +${esc(r.ip||'')} | + + +