nordabiz/templates/admin/security_dashboard.html
Maciej Pienczyn 03bd90f33b feat: Add system status dashboard with tech stack visualization
- Add /admin/status route with real-time system metrics (CPU, RAM, disk)
- Add /api/admin/status API endpoint for auto-refresh
- Add technology stack section showing all platform technologies
- Add auto-refresh (5 min) to GeoIP stats in security dashboard
- Add "Status systemu" link to admin navigation menu
- Fix /health/full endpoint list (remove non-existent endpoints)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:35:25 +01:00

791 lines
27 KiB
HTML

{% extends "base.html" %}
{% block title %}Panel bezpieczeństwa - Admin{% endblock %}
{% block extra_css %}
<style>
.security-header {
margin-bottom: var(--spacing-xl);
}
.security-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.security-header p {
color: var(--text-secondary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
text-align: center;
}
.stat-card.alert {
border-color: #fcd34d;
background: #fffbeb;
}
.stat-card.danger {
border-color: #fca5a5;
background: #fef2f2;
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.section-header h2 {
font-size: var(--font-size-lg);
color: var(--text-primary);
margin: 0;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
.data-table td {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border);
font-size: var(--font-size-sm);
}
.data-table tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
font-weight: 500;
}
.badge-new {
background: #fee2e2;
color: #991b1b;
}
.badge-acknowledged {
background: #fef3c7;
color: #92400e;
}
.badge-resolved {
background: #dcfce7;
color: #166534;
}
.badge-low {
background: #e0e7ff;
color: #3730a3;
}
.badge-medium {
background: #fef3c7;
color: #92400e;
}
.badge-high {
background: #fee2e2;
color: #991b1b;
}
.badge-critical {
background: #7f1d1d;
color: white;
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-xs);
border: none;
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-secondary {
background: var(--background);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-danger {
background: var(--error);
color: white;
}
.action-buttons {
display: flex;
gap: var(--spacing-xs);
}
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
.ip-address {
font-family: monospace;
font-size: var(--font-size-xs);
background: var(--background);
padding: 2px 6px;
border-radius: var(--radius);
}
.timestamp {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.alert-type {
text-transform: uppercase;
font-size: var(--font-size-xs);
font-weight: 600;
}
.locked-row {
background: #fef2f2;
}
.tabs {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--border);
padding-bottom: var(--spacing-md);
}
.tab {
padding: var(--spacing-sm) var(--spacing-md);
border: none;
background: none;
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 500;
border-radius: var(--radius);
}
.tab.active {
background: var(--primary);
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Security mechanisms styles */
.mechanisms-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.mechanism-item {
display: flex;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-md) var(--spacing-lg);
background: var(--background);
border-radius: var(--radius);
border-left: 4px solid transparent;
}
.mechanism-item.stars-5 { border-left-color: #22c55e; }
.mechanism-item.stars-4 { border-left-color: #3b82f6; }
.mechanism-item.stars-3 { border-left-color: #f59e0b; }
.mechanism-stars {
font-size: 1rem;
color: #fbbf24;
min-width: 100px;
letter-spacing: 2px;
}
.mechanism-name {
flex: 1;
font-weight: 500;
color: var(--text-primary);
}
.mechanism-desc {
flex: 2;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.mechanism-status {
font-size: var(--font-size-xs);
padding: 2px 8px;
border-radius: var(--radius);
font-weight: 500;
}
.mechanism-status.active {
background: #dcfce7;
color: #166534;
}
.mechanism-status.inactive {
background: #fee2e2;
color: #991b1b;
}
/* GeoIP stats */
.geoip-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.geoip-stat {
background: var(--background);
padding: var(--spacing-lg);
border-radius: var(--radius);
text-align: center;
}
.geoip-stat-value {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--text-primary);
}
.geoip-stat-label {
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.country-breakdown {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
.country-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
background: var(--background);
border-radius: var(--radius);
font-size: var(--font-size-sm);
}
.country-flag {
font-size: 1.2em;
}
.country-count {
font-weight: 600;
color: var(--text-primary);
}
</style>
{% endblock %}
{% block content %}
<div class="security-header">
<h1>🛡️ Panel bezpieczeństwa</h1>
<p>Monitoring alertów, audit log i zarządzanie bezpieczeństwem</p>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card {% if stats.new_alerts > 0 %}alert{% endif %}">
<div class="stat-value">{{ stats.new_alerts }}</div>
<div class="stat-label">Nowe alerty</div>
</div>
<div class="stat-card {% if stats.locked_accounts > 0 %}danger{% endif %}">
<div class="stat-value">{{ stats.locked_accounts }}</div>
<div class="stat-label">Zablokowane konta</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.users_with_2fa }}/{{ stats.total_admins }}</div>
<div class="stat-label">Admini z 2FA</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.alert_breakdown.values()|sum if stats.alert_breakdown else 0 }}</div>
<div class="stat-label">Wszystkie alerty</div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" onclick="showTab('mechanisms')">Mechanizmy</button>
<button class="tab" onclick="showTab('geoip')">GeoIP</button>
<button class="tab" onclick="showTab('alerts')">Alerty</button>
<button class="tab" onclick="showTab('audit')">Audit log</button>
<button class="tab" onclick="showTab('locked')">Zablokowane konta</button>
</div>
<!-- Mechanisms Tab -->
<div id="tab-mechanisms" class="tab-content active">
<div class="section">
<div class="section-header">
<h2>🔐 Mechanizmy bezpieczeństwa</h2>
</div>
<p style="color: var(--text-secondary); margin-bottom: var(--spacing-lg);">
Lista wszystkich aktywnych mechanizmów ochrony systemu, posortowana według ważności (5★ = krytyczne).
</p>
<div class="mechanisms-list">
<!-- 5 stars - CRITICAL -->
<div class="mechanism-item stars-5">
<span class="mechanism-stars">★★★★★</span>
<span class="mechanism-name">Uwierzytelnianie dwuskładnikowe (2FA)</span>
<span class="mechanism-desc">TOTP przez aplikacje mobilne (Google/Microsoft Authenticator)</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<div class="mechanism-item stars-5">
<span class="mechanism-stars">★★★★★</span>
<span class="mechanism-name">Ochrona przed CSRF</span>
<span class="mechanism-desc">Tokeny CSRF w formularzach (Flask-WTF)</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<div class="mechanism-item stars-5">
<span class="mechanism-stars">★★★★★</span>
<span class="mechanism-name">Szyfrowanie połączeń (HTTPS/TLS)</span>
<span class="mechanism-desc">Let's Encrypt SSL z automatycznym odnowieniem</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<div class="mechanism-item stars-5">
<span class="mechanism-stars">★★★★★</span>
<span class="mechanism-name">Hashowanie haseł</span>
<span class="mechanism-desc">Bezpieczne przechowywanie z Werkzeug (bcrypt)</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<div class="mechanism-item stars-5">
<span class="mechanism-stars">★★★★★</span>
<span class="mechanism-name">Ochrona przed SQL Injection</span>
<span class="mechanism-desc">Parametryzowane zapytania przez SQLAlchemy ORM</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<div class="mechanism-item stars-5">
<span class="mechanism-stars">★★★★★</span>
<span class="mechanism-name">Ochrona przed XSS</span>
<span class="mechanism-desc">Automatyczne escapowanie w szablonach Jinja2</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<!-- 4 stars - IMPORTANT -->
<div class="mechanism-item stars-4">
<span class="mechanism-stars">★★★★☆</span>
<span class="mechanism-name">Blokowanie geograficzne (GeoIP)</span>
<span class="mechanism-desc">Blokada krajów wysokiego ryzyka: RU, CN, KP, IR, BY, SY, VE, CU</span>
<span class="mechanism-status {{ 'active' if geoip_enabled else 'inactive' }}">{{ 'Aktywne' if geoip_enabled else 'Nieaktywne' }}</span>
</div>
<div class="mechanism-item stars-4">
<span class="mechanism-stars">★★★★☆</span>
<span class="mechanism-name">Rate Limiting</span>
<span class="mechanism-desc">Ograniczenie żądań przez Flask-Limiter z Redis</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<div class="mechanism-item stars-4">
<span class="mechanism-stars">★★★★☆</span>
<span class="mechanism-name">Blokada konta</span>
<span class="mechanism-desc">Automatyczna blokada po 5 nieudanych logowaniach</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<div class="mechanism-item stars-4">
<span class="mechanism-stars">★★★★☆</span>
<span class="mechanism-name">Audit Log</span>
<span class="mechanism-desc">Śledzenie wszystkich działań administracyjnych</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<div class="mechanism-item stars-4">
<span class="mechanism-stars">★★★★☆</span>
<span class="mechanism-name">Bezpieczne sesje</span>
<span class="mechanism-desc">Zarządzanie sesjami przez Flask-Login</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<!-- 3 stars - ADDITIONAL -->
<div class="mechanism-item stars-3">
<span class="mechanism-stars">★★★☆☆</span>
<span class="mechanism-name">Honeypot endpoints</span>
<span class="mechanism-desc">Wykrywanie skanerów i botów (/.env, /wp-admin, /phpmyadmin)</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<div class="mechanism-item stars-3">
<span class="mechanism-stars">★★★☆☆</span>
<span class="mechanism-name">Security Alerting</span>
<span class="mechanism-desc">Powiadomienia email o krytycznych zdarzeniach</span>
<span class="mechanism-status active">Aktywne</span>
</div>
<div class="mechanism-item stars-3">
<span class="mechanism-stars">★★★☆☆</span>
<span class="mechanism-name">Security Logging</span>
<span class="mechanism-desc">Centralne logowanie zdarzeń bezpieczeństwa</span>
<span class="mechanism-status active">Aktywne</span>
</div>
</div>
</div>
</div>
<!-- GeoIP Tab -->
<div id="tab-geoip" class="tab-content">
<div class="section">
<div class="section-header">
<h2>🌍 Statystyki GeoIP Blocking</h2>
<div style="text-align: right; font-size: var(--font-size-xs); color: var(--text-secondary);">
<div>Ostatnia aktualizacja: <span id="geoip-timestamp" style="font-family: monospace; font-weight: 500;">{{ generated_at.strftime('%H:%M:%S') }}</span></div>
<div style="margin-top: 4px;">
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #22c55e; margin-right: 4px; animation: pulse 2s infinite;"></span>
Auto-refresh: <span id="geoip-countdown">5:00</span>
</div>
</div>
</div>
{% if geoip_enabled %}
<div class="geoip-stats">
<div class="geoip-stat">
<div class="geoip-stat-value" id="geoip-today">{{ geoip_stats.today }}</div>
<div class="geoip-stat-label">Zablokowanych dziś</div>
</div>
<div class="geoip-stat">
<div class="geoip-stat-value" id="geoip-month">{{ geoip_stats.this_month }}</div>
<div class="geoip-stat-label">W tym miesiącu</div>
</div>
<div class="geoip-stat">
<div class="geoip-stat-value" id="geoip-year">{{ geoip_stats.this_year }}</div>
<div class="geoip-stat-label">W tym roku</div>
</div>
<div class="geoip-stat">
<div class="geoip-stat-value" id="geoip-total">{{ geoip_stats.total }}</div>
<div class="geoip-stat-label">Od początku</div>
</div>
</div>
<h3 style="margin-bottom: var(--spacing-md);">Zablokowane kraje</h3>
<p style="color: var(--text-secondary); font-size: var(--font-size-sm); margin-bottom: var(--spacing-md);">
Blokowane: 🇷🇺 Rosja, 🇨🇳 Chiny, 🇰🇵 Korea Północna, 🇮🇷 Iran, 🇧🇾 Białoruś, 🇸🇾 Syria, 🇻🇪 Wenezuela, 🇨🇺 Kuba
</p>
<div class="country-breakdown" id="country-breakdown">
{% if geoip_stats.by_country %}
{% for country in geoip_stats.by_country %}
<div class="country-badge">
<span class="country-flag">{{ country.flag }}</span>
<span>{{ country.name }}</span>
<span class="country-count">{{ country.count }}</span>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Brak zablokowanych połączeń</p>
</div>
{% endif %}
</div>
{% else %}
<div class="empty-state" style="background: #fef3c7; border-radius: var(--radius-lg); padding: var(--spacing-xl);">
<p style="color: #92400e;">⚠️ GeoIP Blocking jest wyłączone</p>
<p style="color: #78716c; font-size: var(--font-size-sm);">Ustaw GEOIP_ENABLED=true w .env aby włączyć</p>
</div>
{% endif %}
</div>
</div>
<!-- Alerts Tab -->
<div id="tab-alerts" class="tab-content">
<div class="section">
<div class="section-header">
<h2>🚨 Alerty bezpieczeństwa</h2>
</div>
{% if alerts %}
<table class="data-table">
<thead>
<tr>
<th>Typ</th>
<th>Ważność</th>
<th>IP</th>
<th>Użytkownik</th>
<th>Status</th>
<th>Data</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for alert in alerts %}
<tr>
<td><span class="alert-type">{{ alert.alert_type }}</span></td>
<td><span class="badge badge-{{ alert.severity }}">{{ alert.severity }}</span></td>
<td><span class="ip-address">{{ alert.ip_address or '-' }}</span></td>
<td>{{ alert.user_email or '-' }}</td>
<td><span class="badge badge-{{ alert.status }}">{{ alert.status }}</span></td>
<td><span class="timestamp">{{ alert.created_at.strftime('%Y-%m-%d %H:%M') }}</span></td>
<td>
{% if alert.status == 'new' %}
<div class="action-buttons">
<form method="POST" action="{{ url_for('acknowledge_security_alert', alert_id=alert.id) }}" style="display:inline;">
{{ csrf_token() }}
<button type="submit" class="btn-sm btn-secondary">Potwierdź</button>
</form>
<form method="POST" action="{{ url_for('resolve_security_alert', alert_id=alert.id) }}" style="display:inline;">
{{ csrf_token() }}
<button type="submit" class="btn-sm btn-primary">Rozwiąż</button>
</form>
</div>
{% elif alert.status == 'acknowledged' %}
<form method="POST" action="{{ url_for('resolve_security_alert', alert_id=alert.id) }}" style="display:inline;">
{{ csrf_token() }}
<button type="submit" class="btn-sm btn-primary">Rozwiąż</button>
</form>
{% else %}
<span class="timestamp">Rozwiązany</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>✅ Brak alertów bezpieczeństwa</p>
</div>
{% endif %}
</div>
</div>
<!-- Audit Log Tab -->
<div id="tab-audit" class="tab-content">
<div class="section">
<div class="section-header">
<h2>📋 Audit log</h2>
</div>
{% if audit_logs %}
<table class="data-table">
<thead>
<tr>
<th>Użytkownik</th>
<th>Akcja</th>
<th>Encja</th>
<th>IP</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{% for log in audit_logs %}
<tr>
<td>{{ log.user_email }}</td>
<td><code>{{ log.action }}</code></td>
<td>
{{ log.entity_type }}
{% if log.entity_id %}:{{ log.entity_id }}{% endif %}
{% if log.entity_name %}<br><small>{{ log.entity_name }}</small>{% endif %}
</td>
<td><span class="ip-address">{{ log.ip_address or '-' }}</span></td>
<td><span class="timestamp">{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Brak wpisów w audit log</p>
</div>
{% endif %}
</div>
</div>
<!-- Locked Accounts Tab -->
<div id="tab-locked" class="tab-content">
<div class="section">
<div class="section-header">
<h2>🔒 Zablokowane konta</h2>
</div>
{% if locked_accounts %}
<table class="data-table">
<thead>
<tr>
<th>Email</th>
<th>Nieudane próby</th>
<th>Zablokowane do</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for user in locked_accounts %}
<tr class="locked-row">
<td>{{ user.email }}</td>
<td>{{ user.failed_login_attempts }}</td>
<td><span class="timestamp">{{ user.locked_until.strftime('%Y-%m-%d %H:%M') }}</span></td>
<td>
<form method="POST" action="{{ url_for('unlock_account', user_id=user.id) }}" style="display:inline;">
{{ csrf_token() }}
<button type="submit" class="btn-sm btn-danger" onclick="return confirm('Odblokować konto {{ user.email }}?')">
Odblokuj
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>✅ Brak zablokowanych kont</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
function showTab(tabName) {
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
// Show selected tab
document.getElementById('tab-' + tabName).classList.add('active');
event.target.classList.add('active');
}
// GeoIP auto-refresh every 5 minutes
let geoipCountdown = 300;
function updateGeoipCountdown() {
const minutes = Math.floor(geoipCountdown / 60);
const seconds = geoipCountdown % 60;
const countdownEl = document.getElementById('geoip-countdown');
if (countdownEl) {
countdownEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
if (geoipCountdown <= 0) {
refreshGeoipStats();
geoipCountdown = 300;
} else {
geoipCountdown--;
}
}
async function refreshGeoipStats() {
try {
const response = await fetch('/api/admin/security/geoip-stats');
if (response.ok) {
const data = await response.json();
// Update timestamp
const timestamp = new Date(data.timestamp);
const timestampEl = document.getElementById('geoip-timestamp');
if (timestampEl) {
timestampEl.textContent = timestamp.toLocaleTimeString('pl-PL');
}
if (data.enabled) {
// Update stats
const todayEl = document.getElementById('geoip-today');
const monthEl = document.getElementById('geoip-month');
const yearEl = document.getElementById('geoip-year');
const totalEl = document.getElementById('geoip-total');
if (todayEl) todayEl.textContent = data.today;
if (monthEl) monthEl.textContent = data.this_month;
if (yearEl) yearEl.textContent = data.this_year;
if (totalEl) totalEl.textContent = data.total;
// Update country breakdown
const breakdownEl = document.getElementById('country-breakdown');
if (breakdownEl && data.by_country) {
if (data.by_country.length > 0) {
breakdownEl.innerHTML = data.by_country.map(c =>
`<div class="country-badge">
<span class="country-flag">${c.flag}</span>
<span>${c.name}</span>
<span class="country-count">${c.count}</span>
</div>`
).join('');
} else {
breakdownEl.innerHTML = '<div class="empty-state"><p>Brak zablokowanych połączeń</p></div>';
}
}
}
}
} catch (error) {
console.error('Failed to refresh GeoIP stats:', error);
}
}
// Start countdown
setInterval(updateGeoipCountdown, 1000);
// Refresh when page becomes visible
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
refreshGeoipStats();
geoipCountdown = 300;
}
});
{% endblock %}