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:
Maxim Dolgolyov
2026-06-01 15:28:21 +03:00
parent 30626e0928
commit fe122b7681
12 changed files with 262 additions and 2 deletions
+26
View File
@@ -1076,6 +1076,9 @@
<button class="admin-nav-item" data-tab="audit" onclick="switchTab(this)">
<i data-lucide="scroll-text" style="width:15px;height:15px"></i> Аудит-лог
</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)">
<i data-lucide="bug" style="width:15px;height:15px"></i> Ошибки
</button>
@@ -1680,6 +1683,29 @@
<div id="audit-list"></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="section-title" style="display:flex;align-items:center;justify-content:space-between">
+78
View File
@@ -114,6 +114,7 @@
// System tabs (not yet extracted in Phase 2) — load inline
if (name === 'topics') loadTopics();
else if (name === 'audit') loadAuditLog();
else if (name === 'security') loadSecurityLog();
else if (name === 'errors') loadErrorLog();
else if (name === 'health') loadHealth();
else if (name === 'classroom'){ loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
@@ -235,9 +236,14 @@
'user.unban': 'Разблокировка', 'user.delete': 'Удаление', 'user.clear_sessions': 'Очистка истории',
'features.update': 'Фичи обновлены', 'topic.create': 'Создание темы', 'topic.update': 'Редакт. темы',
'topic.delete': 'Удаление темы', 'broadcast': 'Рассылка',
'access.grant': 'Доступ открыт', 'access.deny': 'Доступ закрыт', 'access.inherit': 'Доступ сброшен',
'gam.award': 'Начисление XP/монет', 'gam.reset': 'Сброс прогресса',
'avatar.approve': 'Аватар одобрен', 'avatar.reject': 'Аватар отклонён',
};
const ACTION_COLORS = {
'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">
<thead><tr><th>Дата</th><th>Админ</th><th>Действие</th><th>Цель</th><th>Детали</th><th>IP</th></tr></thead>
@@ -298,6 +304,78 @@
}
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 ════════════════════════════════════════════════ */
let _healthLive = false, _healthTimer = null;