feat(security): implement SOC 2 & ISO 27001 audit logging, password policy, and session timeout

This commit is contained in:
2026-09-11 15:35:47 +02:00
parent 88ec1418cf
commit 0942a94edd
12 changed files with 283 additions and 14 deletions
+128
View File
@@ -68,6 +68,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
");
$stmt->execute([$userId, $isDemo, json_encode($newSettings)]);
fociz_log_audit($userId, 'SETTINGS_UPDATED', 'integration', null, ['provider' => 'OUTLOOK', 'tenant_id' => $tenantId], 'SUCCESS', $adminUser['email']);
$message = "Centrale Microsoft 365 instellingen (Agenda, E-mail & Teams) zijn succesvol opgeslagen.";
$messageType = 'success';
}
@@ -93,6 +95,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
");
$stmt->execute([$userId, json_encode($existingSettings)]);
fociz_log_audit($userId, 'SETTINGS_UPDATED', 'integration', null, ['provider' => 'JIRA', 'domain' => $companyDomain], 'SUCCESS', $adminUser['email']);
$message = "Standaard Atlassian Jira bedrijfsdomein is succesvol opgeslagen.";
$messageType = 'success';
}
@@ -136,6 +140,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt->execute([$filterCode, $filterName, $projectKey ?: null, $jqlClause, $description, $isActive, $sortOrder]);
$message = "Nieuw wachtrijfilter '{$filterName}' is succesvol toegevoegd.";
}
fociz_log_audit($userId, 'SETTINGS_UPDATED', 'jira_queue_filter', $filterId ?: null, ['filter_code' => $filterCode, 'filter_name' => $filterName], 'SUCCESS', $adminUser['email']);
$messageType = 'success';
}
}
@@ -146,6 +151,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($filterId > 0) {
$stmt = $db->prepare("DELETE FROM jira_queue_filters WHERE id = ?");
$stmt->execute([$filterId]);
fociz_log_audit($userId, 'SETTINGS_UPDATED', 'jira_queue_filter', $filterId, ['action' => 'delete'], 'SUCCESS', $adminUser['email']);
$message = "Wachtrijfilter is succesvol verwijderd.";
$messageType = 'success';
}
@@ -160,6 +166,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$fCode = trim($fCode);
$upd->execute([$fCode !== '' ? $fCode : null, (int)$uId]);
}
fociz_log_audit($userId, 'SETTINGS_UPDATED', 'jira_assignments', null, [], 'SUCCESS', $adminUser['email']);
$message = "Standaard Jira wachtrijtoewijzingen voor het team zijn succesvol opgeslagen.";
$messageType = 'success';
}
@@ -185,6 +192,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
set_system_setting('pomodoro_break', $pomoBreak);
set_system_setting('default_task_duration', $taskDuration);
fociz_log_audit($userId, 'SETTINGS_UPDATED', 'workday_settings', null, ['workday_start' => $workdayStart, 'workday_end' => $workdayEnd], 'SUCCESS', $adminUser['email']);
$message = "Organisatie werkdagstandaarden zijn succesvol bijgewerkt.";
$messageType = 'success';
}
@@ -205,6 +214,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
set_system_setting('ai_provider', $aiProvider);
set_system_setting('ai_model', $aiModel);
fociz_log_audit($userId, 'SETTINGS_UPDATED', 'ai_settings', null, ['ai_enabled' => $aiEnabled, 'ai_provider' => $aiProvider, 'ai_model' => $aiModel], 'SUCCESS', $adminUser['email']);
$message = "AI Co-Pilot instellingen zijn succesvol opgeslagen.";
$messageType = 'success';
}
@@ -297,6 +308,13 @@ try {
error_log("Fout bij ophalen jira filters/users: " . $e->getMessage());
}
$auditLogs = [];
try {
$auditLogs = $db->query("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 50")->fetchAll() ?: [];
} catch (Throwable $e) {
error_log("Fout bij ophalen audit logs: " . $e->getMessage());
}
$pageTitle = 'Systeembeheer & Integraties — Fociz';
require_once __DIR__ . '/includes/header.php';
?>
@@ -1194,6 +1212,116 @@ require_once __DIR__ . '/includes/header.php';
</div>
</div>
<!-- Audit Trail Logboek (SOC 2 / ISO 27001) -->
<div class="fociz-card p-6 sm:p-7 space-y-5">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-200 dark:border-slate-800 pb-5">
<div>
<div class="flex items-center space-x-2 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider mb-1">
<i data-lucide="shield-check" class="w-4 h-4"></i>
<span>Compliance & Veiligheidslogboek (SOC 2 / ISO 27001)</span>
</div>
<h2 class="text-xl font-bold text-slate-900 dark:text-white tracking-tight">
Onwijzigbare Audit Trail
</h2>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Centrale registratie van alle inlogpogingen, 2FA-verificaties, IP-detecties en beheerdersacties conform NIST SP 800-63B.
</p>
</div>
<div class="flex items-center space-x-2 flex-shrink-0">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1.5 animate-pulse"></span>
Live Audit Logging Actief
</span>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs text-slate-600 dark:text-slate-300">
<thead>
<tr class="border-b border-slate-200 dark:border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
<th class="py-3 px-3">Tijdstip</th>
<th class="py-3 px-3">Gebruiker / Doel</th>
<th class="py-3 px-3">Actie</th>
<th class="py-3 px-3">IP-adres</th>
<th class="py-3 px-3">Status</th>
<th class="py-3 px-3">Details</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/60 font-sans">
<?php if (empty($auditLogs)): ?>
<tr>
<td colspan="6" class="py-8 text-center text-slate-400 dark:text-slate-500">
<i data-lucide="inbox" class="w-8 h-8 mx-auto mb-2 opacity-40"></i>
Nog geen beveiligingsgebeurtenissen geregistreerd.
</td>
</tr>
<?php else: ?>
<?php foreach ($auditLogs as $log):
$status = strtoupper($log['status'] ?? 'SUCCESS');
$statusColor = match($status) {
'SUCCESS' => 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20',
'FAILED', 'FAILURE' => 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20',
'WARNING' => 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20',
'PENDING' => 'bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20',
default => 'bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20',
};
$action = $log['action'] ?? '';
$actionBadge = match($action) {
'LOGIN_SUCCESS' => 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800/60',
'LOGIN_FAILED', 'LOGIN_MFA_FAILED' => 'bg-rose-50 dark:bg-rose-950/40 text-rose-700 dark:text-rose-300 border-rose-200 dark:border-rose-800/60',
'LOGIN_NEW_IP_CHALLENGE', 'IP_VERIFIED' => 'bg-sky-50 dark:bg-sky-950/40 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800/60',
'SETTINGS_UPDATED' => 'bg-purple-50 dark:bg-purple-950/40 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800/60',
'USER_INVITED', 'INVITE_RESENT', 'USER_REGISTERED_VIA_INVITE' => 'bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800/60',
'USER_DELETED', 'INVITE_REVOKED' => 'bg-amber-50 dark:bg-amber-950/40 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800/60',
default => 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700',
};
$detailsStr = '';
if (!empty($log['details'])) {
$decoded = json_decode($log['details'], true);
if (is_array($decoded)) {
$parts = [];
foreach ($decoded as $k => $v) {
if (is_scalar($v)) {
$parts[] = "{$k}: {$v}";
}
}
$detailsStr = implode(', ', $parts);
} else {
$detailsStr = (string)$log['details'];
}
}
?>
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/40 transition">
<td class="py-2.5 px-3 font-mono text-[11px] text-slate-500 whitespace-nowrap">
<?= date('d-m-Y H:i:s', strtotime($log['created_at'])) ?>
</td>
<td class="py-2.5 px-3 font-medium text-slate-800 dark:text-slate-200 max-w-[200px] truncate" title="<?= htmlspecialchars($log['user_email'] ?? 'Systeem') ?>">
<?= htmlspecialchars($log['user_email'] ?? 'Systeem / Onbekend') ?>
</td>
<td class="py-2.5 px-3 whitespace-nowrap">
<span class="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-mono font-medium border <?= $actionBadge ?>">
<?= htmlspecialchars($action) ?>
</span>
</td>
<td class="py-2.5 px-3 font-mono text-[11px] text-slate-500 whitespace-nowrap">
<?= htmlspecialchars($log['ip_address'] ?? '—') ?>
</td>
<td class="py-2.5 px-3 whitespace-nowrap">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border <?= $statusColor ?>">
<?= htmlspecialchars($status) ?>
</span>
</td>
<td class="py-2.5 px-3 text-[11px] text-slate-500 max-w-[280px] truncate" title="<?= htmlspecialchars($detailsStr) ?>">
<?= htmlspecialchars($detailsStr ?: '—') ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>