Merge pull request 'feat(security): implement SOC 2 & ISO 27001 audit logging, password policy, and session timeout' (#49) from dev into main

Reviewed-on: #49
This commit was merged in pull request #49.
This commit is contained in:
2026-09-11 13:43:22 +00:00
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)]); $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."; $message = "Centrale Microsoft 365 instellingen (Agenda, E-mail & Teams) zijn succesvol opgeslagen.";
$messageType = 'success'; $messageType = 'success';
} }
@@ -93,6 +95,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
"); ");
$stmt->execute([$userId, json_encode($existingSettings)]); $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."; $message = "Standaard Atlassian Jira bedrijfsdomein is succesvol opgeslagen.";
$messageType = 'success'; $messageType = 'success';
} }
@@ -136,6 +140,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt->execute([$filterCode, $filterName, $projectKey ?: null, $jqlClause, $description, $isActive, $sortOrder]); $stmt->execute([$filterCode, $filterName, $projectKey ?: null, $jqlClause, $description, $isActive, $sortOrder]);
$message = "Nieuw wachtrijfilter '{$filterName}' is succesvol toegevoegd."; $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'; $messageType = 'success';
} }
} }
@@ -146,6 +151,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($filterId > 0) { if ($filterId > 0) {
$stmt = $db->prepare("DELETE FROM jira_queue_filters WHERE id = ?"); $stmt = $db->prepare("DELETE FROM jira_queue_filters WHERE id = ?");
$stmt->execute([$filterId]); $stmt->execute([$filterId]);
fociz_log_audit($userId, 'SETTINGS_UPDATED', 'jira_queue_filter', $filterId, ['action' => 'delete'], 'SUCCESS', $adminUser['email']);
$message = "Wachtrijfilter is succesvol verwijderd."; $message = "Wachtrijfilter is succesvol verwijderd.";
$messageType = 'success'; $messageType = 'success';
} }
@@ -160,6 +166,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$fCode = trim($fCode); $fCode = trim($fCode);
$upd->execute([$fCode !== '' ? $fCode : null, (int)$uId]); $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."; $message = "Standaard Jira wachtrijtoewijzingen voor het team zijn succesvol opgeslagen.";
$messageType = 'success'; $messageType = 'success';
} }
@@ -185,6 +192,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
set_system_setting('pomodoro_break', $pomoBreak); set_system_setting('pomodoro_break', $pomoBreak);
set_system_setting('default_task_duration', $taskDuration); 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."; $message = "Organisatie werkdagstandaarden zijn succesvol bijgewerkt.";
$messageType = 'success'; $messageType = 'success';
} }
@@ -205,6 +214,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
set_system_setting('ai_provider', $aiProvider); set_system_setting('ai_provider', $aiProvider);
set_system_setting('ai_model', $aiModel); 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."; $message = "AI Co-Pilot instellingen zijn succesvol opgeslagen.";
$messageType = 'success'; $messageType = 'success';
} }
@@ -297,6 +308,13 @@ try {
error_log("Fout bij ophalen jira filters/users: " . $e->getMessage()); 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'; $pageTitle = 'Systeembeheer & Integraties — Fociz';
require_once __DIR__ . '/includes/header.php'; require_once __DIR__ . '/includes/header.php';
?> ?>
@@ -1194,6 +1212,116 @@ require_once __DIR__ . '/includes/header.php';
</div> </div>
</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> </div>
<script> <script>
+15 -5
View File
@@ -274,7 +274,9 @@ try {
$stmt = $db->prepare("UPDATE board_lanes SET title = ?, color = ? WHERE id = ? AND board_id = ?"); $stmt = $db->prepare("UPDATE board_lanes SET title = ?, color = ? WHERE id = ? AND board_id = ?");
$stmt->execute([$title, $color, $laneId, $boardId]); $stmt->execute([$title, $color, $laneId, $boardId]);
} else { } else {
$maxPos = (int)$db->query("SELECT COALESCE(MAX(position), -1) FROM board_lanes WHERE board_id = {$boardId}")->fetchColumn(); $stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_lanes WHERE board_id = ?");
$stmtMax->execute([$boardId]);
$maxPos = (int)$stmtMax->fetchColumn();
$stmt = $db->prepare("INSERT INTO board_lanes (board_id, title, color, position) VALUES (?, ?, ?, ?)"); $stmt = $db->prepare("INSERT INTO board_lanes (board_id, title, color, position) VALUES (?, ?, ?, ?)");
$stmt->execute([$boardId, $title, $color, $maxPos + 1]); $stmt->execute([$boardId, $title, $color, $maxPos + 1]);
$laneId = (int)$db->lastInsertId(); $laneId = (int)$db->lastInsertId();
@@ -296,7 +298,9 @@ try {
} }
// Controleer of er kaarten in staan // Controleer of er kaarten in staan
$cardCount = (int)$db->query("SELECT COUNT(*) FROM board_cards WHERE lane_id = {$laneId}")->fetchColumn(); $stmtCardCnt = $db->prepare("SELECT COUNT(*) FROM board_cards WHERE lane_id = ?");
$stmtCardCnt->execute([$laneId]);
$cardCount = (int)$stmtCardCnt->fetchColumn();
if ($cardCount > 0) { if ($cardCount > 0) {
echo json_encode(['success' => false, 'error' => 'Verplaats of verwijder eerst de taken in deze kolom alvorens de kolom te verwijderen.']); echo json_encode(['success' => false, 'error' => 'Verplaats of verwijder eerst de taken in deze kolom alvorens de kolom te verwijderen.']);
exit; exit;
@@ -337,7 +341,9 @@ try {
"); ");
$stmt->execute([$title, $description, $assigneeId, $priority, $color, $laneId, $cardId, $boardId]); $stmt->execute([$title, $description, $assigneeId, $priority, $color, $laneId, $cardId, $boardId]);
} else { } else {
$maxPos = (int)$db->query("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = {$laneId}")->fetchColumn(); $stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
$stmtMax->execute([$laneId]);
$maxPos = (int)$stmtMax->fetchColumn();
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id) INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -563,10 +569,14 @@ try {
// Als geen lane_id is meegegeven, pak de 1e lane van het bord // Als geen lane_id is meegegeven, pak de 1e lane van het bord
if ($laneId <= 0) { if ($laneId <= 0) {
$laneId = (int)$db->query("SELECT id FROM board_lanes WHERE board_id = {$boardId} ORDER BY position ASC, id ASC LIMIT 1")->fetchColumn(); $stmtLane = $db->prepare("SELECT id FROM board_lanes WHERE board_id = ? ORDER BY position ASC, id ASC LIMIT 1");
$stmtLane->execute([$boardId]);
$laneId = (int)$stmtLane->fetchColumn();
} }
$maxPos = (int)$db->query("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = {$laneId}")->fetchColumn(); $stmtMax = $db->prepare("SELECT COALESCE(MAX(position), -1) FROM board_cards WHERE lane_id = ?");
$stmtMax->execute([$laneId]);
$maxPos = (int)$stmtMax->fetchColumn();
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id) INSERT INTO board_cards (board_id, lane_id, title, description, assignee_id, priority, color, position, created_by_id)
VALUES (?, ?, ?, '', NULL, 'P2', 'yellow', ?, ?) VALUES (?, ?, ?, '', NULL, 'P2', 'yellow', ?, ?)
+4 -2
View File
@@ -39,8 +39,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($name) || empty($password) || empty($totpCode)) { if (empty($name) || empty($password) || empty($totpCode)) {
$error = "Alle velden (inclusief 2FA verificatiecode) zijn verplicht."; $error = "Alle velden (inclusief 2FA verificatiecode) zijn verplicht.";
} elseif (strlen($password) < 8) { } elseif (($pwErr = fociz_validate_password($password)) !== null) {
$error = "Het wachtwoord moet minimaal 8 tekens lang zijn."; $error = $pwErr;
} elseif ($password !== $confirmPassword) { } elseif ($password !== $confirmPassword) {
$error = "Wachtwoorden komen niet overeen."; $error = "Wachtwoorden komen niet overeen.";
} elseif (!TOTP::verifyCode($secret, $totpCode)) { } elseif (!TOTP::verifyCode($secret, $totpCode)) {
@@ -75,6 +75,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$db->commit(); $db->commit();
fociz_log_audit($newUserId, 'USER_REGISTERED_VIA_INVITE', 'user', $newUserId, ['email' => $invite['email'], 'role' => $invite['role']], 'SUCCESS', $invite['email']);
// Start dagsessie (geldig tot 23:59:59) // Start dagsessie (geldig tot 23:59:59)
$_SESSION['user_id'] = $newUserId; $_SESSION['user_id'] = $newUserId;
$_SESSION['user_name'] = $name; $_SESSION['user_name'] = $name;
+5
View File
@@ -74,6 +74,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$isIpKnown) { if (!$isIpKnown) {
// Stuur verificatie-mail en informeer gebruiker // Stuur verificatie-mail en informeer gebruiker
fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp); fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp);
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_CHALLENGE', 'user', (int)$user['id'], ['ip' => $clientIp], 'PENDING', $user['email']);
$error = "Nieuwe inloglocatie gedetecteerd ({$clientIp}). Er is een verificatie-e-mail gestuurd naar {$user['email']}. Klik op de link in de e-mail om deze locatie toe te staan."; $error = "Nieuwe inloglocatie gedetecteerd ({$clientIp}). Er is een verificatie-e-mail gestuurd naar {$user['email']}. Klik op de link in de e-mail om deze locatie toe te staan.";
} else { } else {
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA // Succesvol stap 1: zet tijdelijke pending sessie voor 2FA
@@ -87,6 +88,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!empty($email)) { if (!empty($email)) {
fociz_hit_rate_limit($emailRateKey, 900); fociz_hit_rate_limit($emailRateKey, 900);
} }
fociz_log_audit($user ? (int)$user['id'] : null, 'LOGIN_FAILED', 'user', $user ? (int)$user['id'] : null, ['reason' => 'Invalid credentials', 'ip' => $clientIp], 'FAILURE', $email);
$error = "Onjuist e-mailadres of wachtwoord."; $error = "Onjuist e-mailadres of wachtwoord.";
} }
} }
@@ -159,6 +161,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Dagsessie-token uitgeven (strikt geldig tot 23:59:59 van vandaag) // Dagsessie-token uitgeven (strikt geldig tot 23:59:59 van vandaag)
issue_day_session_token((int)$user['id']); issue_day_session_token((int)$user['id']);
fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA'], 'SUCCESS', $user['email']);
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']); unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']);
header('Location: /planner.php'); header('Location: /planner.php');
@@ -166,6 +170,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} else { } else {
fociz_hit_rate_limit($totpRateKey, 900); fociz_hit_rate_limit($totpRateKey, 900);
fociz_hit_rate_limit($ipRateKey, 900); fociz_hit_rate_limit($ipRateKey, 900);
fociz_log_audit((int)$userId, 'LOGIN_MFA_FAILED', 'user', (int)$userId, ['ip' => $clientIp], 'FAILURE', $_SESSION['pending_2fa_user_email'] ?? null);
$error = "Onjuiste 2FA code. Controleer uw authenticator app en probeer opnieuw."; $error = "Onjuiste 2FA code. Controleer uw authenticator app en probeer opnieuw.";
} }
} }
+4 -2
View File
@@ -37,8 +37,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $resetRecord) {
if (empty($password)) { if (empty($password)) {
$error = "Vul een nieuw wachtwoord in."; $error = "Vul een nieuw wachtwoord in.";
} elseif (strlen($password) < 8) { } elseif (($pwErr = fociz_validate_password($password)) !== null) {
$error = "Het wachtwoord moet minimaal 8 tekens lang zijn."; $error = $pwErr;
} elseif ($password !== $confirmPassword) { } elseif ($password !== $confirmPassword) {
$error = "De wachtwoorden komen niet overeen."; $error = "De wachtwoorden komen niet overeen.";
} else { } else {
@@ -62,6 +62,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $resetRecord) {
$db->commit(); $db->commit();
$success = true; $success = true;
fociz_log_audit(null, 'PASSWORD_RESET_COMPLETED', 'user', null, ['email' => $resetRecord['user_email']], 'SUCCESS', $resetRecord['user_email']);
} catch (Throwable $e) { } catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack(); if ($db->inTransaction()) $db->rollBack();
error_log("Fout bij resetten wachtwoord: " . $e->getMessage()); error_log("Fout bij resetten wachtwoord: " . $e->getMessage());
+4 -2
View File
@@ -35,8 +35,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$error = "Alle velden (inclusief de 2FA verificatiecode) zijn verplicht."; $error = "Alle velden (inclusief de 2FA verificatiecode) zijn verplicht.";
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) { } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = "Voer een geldig e-mailadres in."; $error = "Voer een geldig e-mailadres in.";
} elseif (strlen($password) < 8) { } elseif (($pwErr = fociz_validate_password($password)) !== null) {
$error = "Het wachtwoord moet minimaal 8 tekens bevatten."; $error = $pwErr;
} elseif ($password !== $confirmPassword) { } elseif ($password !== $confirmPassword) {
$error = "De ingevoerde wachtwoorden komen niet overeen."; $error = "De ingevoerde wachtwoorden komen niet overeen.";
} elseif (!TOTP::verifyCode($secret, $totpCode)) { } elseif (!TOTP::verifyCode($secret, $totpCode)) {
@@ -73,6 +73,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
clear_remember_cookie(); clear_remember_cookie();
unset($_SESSION['setup_totp_secret']); unset($_SESSION['setup_totp_secret']);
fociz_log_audit($adminId, 'INITIAL_ADMIN_SETUP', 'user', $adminId, ['email' => $email, 'name' => $name], 'SUCCESS', $email);
$success = true; $success = true;
} catch (Exception $e) { } catch (Exception $e) {
$error = "Fout bij aanmaken beheerder in MariaDB: " . $e->getMessage(); $error = "Fout bij aanmaken beheerder in MariaDB: " . $e->getMessage();
+2 -1
View File
@@ -1,4 +1,4 @@
<?php <?php
/** /**
* Fociz Nieuwe Inloglocatie / IP Verificatie * Fociz Nieuwe Inloglocatie / IP Verificatie
*/ */
@@ -43,6 +43,7 @@ if (empty($token) || !preg_match('/^[a-f0-9]{64}$/', $token)) {
$success = true; $success = true;
$userEmail = $record['email']; $userEmail = $record['email'];
$ipAddress = $record['ip_address']; $ipAddress = $record['ip_address'];
fociz_log_audit((int)$record['user_id'], 'IP_VERIFIED', 'user_known_ips', (int)$record['id'], ['ip' => $ipAddress], 'SUCCESS', $userEmail);
} }
} catch (Throwable $e) { } catch (Throwable $e) {
error_log("verify-ip.php error: " . $e->getMessage()); error_log("verify-ip.php error: " . $e->getMessage());
+2
View File
@@ -160,6 +160,8 @@ function ensure_tables_exist(PDO $pdo): void {
"ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(45) NULL AFTER last_jira_check_at", "ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(45) NULL AFTER last_jira_check_at",
"ALTER TABLE users ADD COLUMN last_login_at DATETIME NULL AFTER last_login_ip", "ALTER TABLE users ADD COLUMN last_login_at DATETIME NULL AFTER last_login_ip",
"CREATE TABLE IF NOT EXISTS `user_known_ips` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `is_verified` TINYINT(1) NOT NULL DEFAULT 1, `verify_token` VARCHAR(64) NULL, `verify_token_expires_at` DATETIME NULL, `first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`), INDEX (`verify_token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", "CREATE TABLE IF NOT EXISTS `user_known_ips` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `is_verified` TINYINT(1) NOT NULL DEFAULT 1, `verify_token` VARCHAR(64) NULL, `verify_token_expires_at` DATETIME NULL, `first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_ip` (`user_id`, `ip_address`), INDEX (`verify_token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
// SOC 2 & ISO 27001: Onwijzigbare Audit Trail
"CREATE TABLE IF NOT EXISTS `audit_logs` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NULL, `user_email` VARCHAR(191) NULL, `action` VARCHAR(64) NOT NULL, `resource_type` VARCHAR(64) NULL, `resource_id` INT NULL, `details` TEXT NULL, `ip_address` VARCHAR(45) NOT NULL, `user_agent` VARCHAR(255) NULL, `status` ENUM('SUCCESS', 'FAILED', 'WARNING', 'PENDING') NOT NULL DEFAULT 'SUCCESS', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`user_id`), INDEX (`action`), INDEX (`created_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
]; ];
foreach ($colUpgrades as $sql) { foreach ($colUpgrades as $sql) {
try { try {
+18
View File
@@ -275,3 +275,21 @@ CREATE TABLE IF NOT EXISTS `user_known_ips` (
INDEX (`verify_token`), INDEX (`verify_token`),
CONSTRAINT `fk_known_ips_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE CONSTRAINT `fk_known_ips_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Fociz SOC 2 & ISO 27001: Onwijzigbare Audit Trail (Beveiligings- en beheeracties)
CREATE TABLE IF NOT EXISTS `audit_logs` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NULL,
`user_email` VARCHAR(191) NULL,
`action` VARCHAR(64) NOT NULL,
`resource_type` VARCHAR(64) NULL,
`resource_id` INT NULL,
`details` TEXT NULL,
`ip_address` VARCHAR(45) NOT NULL,
`user_agent` VARCHAR(255) NULL,
`status` ENUM('SUCCESS', 'FAILED', 'WARNING', 'PENDING') NOT NULL DEFAULT 'SUCCESS',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (`user_id`),
INDEX (`action`),
INDEX (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+89
View File
@@ -87,6 +87,83 @@ function fociz_get_client_ip(): string {
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
} }
/**
* SOC 2 & ISO 27001 Audit Trail logger
* Schrijft alle beveiligings- en beheeracties onweerlegbaar weg naar de audit_logs tabel
*/
function fociz_log_audit(?int $userId, string $action, string $resourceType = '', ?int $resourceId = null, $details = null, string $status = 'SUCCESS', ?string $userEmail = null): void {
try {
$db = get_db_connection();
$ip = fociz_get_client_ip();
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
// Indien email niet meegegeven, zoek op basis van userId
if (empty($userEmail) && !empty($userId)) {
$stmtU = $db->prepare("SELECT email FROM users WHERE id = ?");
$stmtU->execute([$userId]);
$userEmail = $stmtU->fetchColumn() ?: null;
}
$detailsJson = is_array($details) || is_object($details) ? json_encode($details, JSON_UNESCAPED_UNICODE) : (is_string($details) ? $details : null);
$statusNormalized = strtoupper($status);
if ($statusNormalized === 'FAILURE') {
$statusNormalized = 'FAILED';
}
if (!in_array($statusNormalized, ['SUCCESS', 'FAILED', 'WARNING', 'PENDING'])) {
$statusNormalized = 'SUCCESS';
}
$stmt = $db->prepare("
INSERT INTO audit_logs (user_id, user_email, action, resource_type, resource_id, details, ip_address, user_agent, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
");
$stmt->execute([
$userId ?: null,
$userEmail ?: null,
$action,
$resourceType ?: null,
$resourceId ?: null,
$detailsJson,
$ip,
$ua,
$statusNormalized
]);
} catch (Throwable $e) {
// Audit log mag NOOIT de applicatiewerking breken
error_log("fociz_log_audit failure: " . $e->getMessage());
}
}
/**
* NIST SP 800-63B / ISO 27001 Wachtwoordvalidator
* Vereist:
* - Minimaal 12 tekens
* - Minstens 1 hoofdletter (A-Z)
* - Minstens 1 kleine letter (a-z)
* - Minstens 1 cijfer (0-9)
* - Minstens 1 speciaal teken (!@#$%^&*()_+-=[]{}|;:,.<>?)
* Geeft null terug indien geldig, of een duidelijke foutmelding in het Nederlands
*/
function fociz_validate_password(string $password): ?string {
if (strlen($password) < 12) {
return "Het wachtwoord moet minimaal 12 tekens lang zijn conform het beveiligingsbeleid.";
}
if (!preg_match('/[A-Z]/', $password)) {
return "Het wachtwoord moet minimaal één hoofdletter (A-Z) bevatten.";
}
if (!preg_match('/[a-z]/', $password)) {
return "Het wachtwoord moet minimaal één kleine letter (a-z) bevatten.";
}
if (!preg_match('/[0-9]/', $password)) {
return "Het wachtwoord moet minimaal één cijfer (0-9) bevatten.";
}
if (!preg_match('/[^A-Za-z0-9]/', $password)) {
return "Het wachtwoord moet minimaal één speciaal teken of leesteken bevatten.";
}
return null;
}
/** /**
* Genereert een sessie fingerprint op basis van IP en browser/platform * Genereert een sessie fingerprint op basis van IP en browser/platform
*/ */
@@ -395,6 +472,18 @@ function get_logged_in_user(): ?array {
// 2. Controleer actieve PHP sessie (mits behorend bij vandaag én hetzelfde IP/fingerprint) // 2. Controleer actieve PHP sessie (mits behorend bij vandaag én hetzelfde IP/fingerprint)
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) { if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
// Inactiviteitstimeout (ISO 27001 / SOC 2): 120 minuten (7200s)
$maxIdleSeconds = 7200;
if (!empty($_SESSION['last_activity']) && ($now - (int)$_SESSION['last_activity']) > $maxIdleSeconds) {
fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_IDLE_TIMEOUT', 'users', (int)$_SESSION['user_id'], ['idle_seconds' => $now - (int)$_SESSION['last_activity']], 'WARNING');
$_SESSION = ['session_was_expired' => true, 'idle_timeout' => true];
clear_day_session_cookie();
if (session_status() === PHP_SESSION_ACTIVE) {
@session_regenerate_id(true);
}
return null;
}
// IP-verificatie: als het IP/locatie tussentijds verandert, moet er opnieuw worden ingelogd // IP-verificatie: als het IP/locatie tussentijds verandert, moet er opnieuw worden ingelogd
if (!empty($_SESSION['auth_fingerprint']) && $_SESSION['auth_fingerprint'] !== $clientFingerprint) { if (!empty($_SESSION['auth_fingerprint']) && $_SESSION['auth_fingerprint'] !== $clientFingerprint) {
$_SESSION = ['session_was_expired' => true, 'ip_changed' => true]; $_SESSION = ['session_was_expired' => true, 'ip_changed' => true];
+3 -2
View File
@@ -175,8 +175,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!password_verify($currentPassword, $storedHash)) { if (!password_verify($currentPassword, $storedHash)) {
$message = "Uw huidige wachtwoord is niet correct ingevoerd."; $message = "Uw huidige wachtwoord is niet correct ingevoerd.";
$messageType = 'error'; $messageType = 'error';
} elseif (strlen($newPassword) < 8) { } elseif (($pwErr = fociz_validate_password($newPassword)) !== null) {
$message = "Het nieuwe wachtwoord moet minimaal 8 tekens lang zijn."; $message = $pwErr;
$messageType = 'error'; $messageType = 'error';
} elseif ($newPassword !== $confirmPassword) { } elseif ($newPassword !== $confirmPassword) {
$message = "Het nieuwe wachtwoord en de bevestiging komen niet overeen."; $message = "Het nieuwe wachtwoord en de bevestiging komen niet overeen.";
@@ -185,6 +185,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$newHash = password_hash($newPassword, PASSWORD_DEFAULT); $newHash = password_hash($newPassword, PASSWORD_DEFAULT);
$stmtUp = $db->prepare("UPDATE users SET password_hash = ? WHERE id = ?"); $stmtUp = $db->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
$stmtUp->execute([$newHash, $userId]); $stmtUp->execute([$newHash, $userId]);
fociz_log_audit($userId, 'PASSWORD_CHANGED', 'user', $userId, ['method' => 'profile'], 'SUCCESS', $user['email']);
$message = "Uw wachtwoord is succesvol gewijzigd."; $message = "Uw wachtwoord is succesvol gewijzigd.";
$messageType = 'success'; $messageType = 'success';
} }
+9
View File
@@ -39,6 +39,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Verstuur de gestylede uitnodigingsmail direct via SMTP // Verstuur de gestylede uitnodigingsmail direct via SMTP
$mailResult = FocizMailer::sendInvitationEmail($email, $adminUser['name'], $role, $generatedLink); $mailResult = FocizMailer::sendInvitationEmail($email, $adminUser['name'], $role, $generatedLink);
fociz_log_audit((int)$adminUser['id'], 'USER_INVITED', 'invite', null, ['email' => $email, 'role' => $role], 'SUCCESS', $adminUser['email']);
if ($mailResult['success']) { if ($mailResult['success']) {
$message = "Uitnodigingslink succesvol aangemaakt én per e-mail verzonden naar {$email}!"; $message = "Uitnodigingslink succesvol aangemaakt én per e-mail verzonden naar {$email}!";
} else { } else {
@@ -55,6 +56,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$baseUrl = rtrim(APP_URL, '/'); $baseUrl = rtrim(APP_URL, '/');
$link = "{$baseUrl}/auth/invite.php?token={$inv['token']}"; $link = "{$baseUrl}/auth/invite.php?token={$inv['token']}";
$mailResult = FocizMailer::sendInvitationEmail($inv['email'], $adminUser['name'], $inv['role'], $link); $mailResult = FocizMailer::sendInvitationEmail($inv['email'], $adminUser['name'], $inv['role'], $link);
fociz_log_audit((int)$adminUser['id'], 'INVITE_RESENT', 'invite', $inviteId, ['email' => $inv['email'], 'role' => $inv['role']], 'SUCCESS', $adminUser['email']);
if ($mailResult['success']) { if ($mailResult['success']) {
$message = "Uitnodiging succesvol opnieuw per e-mail verzonden naar {$inv['email']}!"; $message = "Uitnodiging succesvol opnieuw per e-mail verzonden naar {$inv['email']}!";
} else { } else {
@@ -67,13 +69,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$inviteId = (int)($_POST['invite_id'] ?? 0); $inviteId = (int)($_POST['invite_id'] ?? 0);
$stmtDel = $db->prepare("DELETE FROM invites WHERE id = ?"); $stmtDel = $db->prepare("DELETE FROM invites WHERE id = ?");
$stmtDel->execute([$inviteId]); $stmtDel->execute([$inviteId]);
fociz_log_audit((int)$adminUser['id'], 'INVITE_REVOKED', 'invite', $inviteId, [], 'SUCCESS', $adminUser['email']);
header('Location: /users.php'); header('Location: /users.php');
exit; exit;
} elseif ($action === 'delete_user') { } elseif ($action === 'delete_user') {
$delUserId = (int)($_POST['user_id'] ?? 0); $delUserId = (int)($_POST['user_id'] ?? 0);
if ($delUserId !== (int)$adminUser['id']) { if ($delUserId !== (int)$adminUser['id']) {
$stmtUserToDel = $db->prepare("SELECT email, name FROM users WHERE id = ?");
$stmtUserToDel->execute([$delUserId]);
$delUser = $stmtUserToDel->fetch();
$stmtDelUser = $db->prepare("DELETE FROM users WHERE id = ?"); $stmtDelUser = $db->prepare("DELETE FROM users WHERE id = ?");
$stmtDelUser->execute([$delUserId]); $stmtDelUser->execute([$delUserId]);
fociz_log_audit((int)$adminUser['id'], 'USER_DELETED', 'user', $delUserId, ['deleted_email' => $delUser['email'] ?? null, 'deleted_name' => $delUser['name'] ?? null], 'SUCCESS', $adminUser['email']);
} }
header('Location: /users.php'); header('Location: /users.php');
exit; exit;