Security
This commit is contained in:
@@ -56,6 +56,7 @@ if ($action === 'logout') {
|
||||
$stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
fociz_terminate_session(session_id());
|
||||
fociz_log_audit($userId, 'LOGOUT_SESSION_GUARD', 'users', $userId, ['ip' => fociz_get_client_ip()], 'SUCCESS', $currentUser['email'] ?? null);
|
||||
} catch (Throwable $e) {
|
||||
error_log("Session guard logout error: " . $e->getMessage());
|
||||
@@ -72,6 +73,51 @@ if ($action === 'logout') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Haal actieve apparaten en sessies op (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
if ($action === 'get_active_sessions') {
|
||||
$sessions = fociz_get_user_active_sessions($userId, session_id());
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'sessions' => $sessions,
|
||||
'timestamp' => $now
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Beëindig alle overige actieve sessies (SOC 2 CC6.1)
|
||||
if ($action === 'revoke_other_sessions') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldig CSRF beveiligingstoken.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$count = fociz_revoke_other_sessions($userId, session_id());
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'revoked_count' => $count,
|
||||
'message' => "Succesvol {$count} andere actieve sessie(s) op afstand beëindigd."
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Beëindig specifieke sessie op afstand
|
||||
if ($action === 'revoke_session') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? null)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Ongeldig CSRF beveiligingstoken.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sessionDbId = (int)($_POST['session_id'] ?? 0);
|
||||
$ok = fociz_revoke_session_by_id($userId, $sessionDbId);
|
||||
echo json_encode([
|
||||
'success' => $ok,
|
||||
'message' => $ok ? 'Sessie succesvol op afstand beëindigd.' : 'Kan sessie niet intrekken.'
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Onbekende sessieactie.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
|
||||
+13
-1
@@ -77,15 +77,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
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 met verse sessie ID (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6)
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
$_SESSION['user_id'] = $newUserId;
|
||||
$_SESSION['user_name'] = $name;
|
||||
$_SESSION['user_email'] = $invite['email'];
|
||||
$_SESSION['user_role'] = $invite['role'];
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
$_SESSION['last_activity'] = time();
|
||||
$_SESSION['login_time'] = time();
|
||||
$_SESSION['last_rotated'] = time();
|
||||
clear_remember_cookie();
|
||||
issue_day_session_token($newUserId);
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
fociz_register_active_session($newUserId, $newSessionId, $maxIdleSeconds);
|
||||
unset($_SESSION['invite_totp_secret']);
|
||||
|
||||
header('Location: /planner.php');
|
||||
|
||||
@@ -24,6 +24,9 @@ $infoMessage = null;
|
||||
if (isset($_GET['expired'])) {
|
||||
$infoMessage = "Uw sessie is beëindigd wegens inactiviteit of een dagwissel. Log opnieuw in om verder te gaan.";
|
||||
}
|
||||
if (isset($_GET['revoked'])) {
|
||||
$infoMessage = "Uw sessie is op afstand beëindigd of ingetrokken. Log opnieuw in om verder te gaan.";
|
||||
}
|
||||
if (isset($_GET['ip_changed'])) {
|
||||
$infoMessage = "U bent ingelogd vanaf een andere netwerklocatie. Uit veiligheidsoverwegingen dient u opnieuw in te loggen met 2FA.";
|
||||
}
|
||||
@@ -148,6 +151,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Volledige login succesvol (geldig tot 23:59:59 van vandaag)
|
||||
fociz_register_user_ip((int)$user['id'], $clientIp, true);
|
||||
|
||||
// Session Fixation Beveiliging (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6):
|
||||
// Genereer direct een nieuw cryptografisch sessie-ID en vernietig de pre-auth sessie
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['user_name'] = $user['name'];
|
||||
$_SESSION['user_email'] = $user['email'];
|
||||
@@ -163,6 +173,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Dagsessie-token uitgeven (strikt geldig tot 23:59:59 van vandaag)
|
||||
issue_day_session_token((int)$user['id']);
|
||||
|
||||
// Registreer actieve sessie in user_sessions (SOC 2 / ISO 27001)
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
fociz_register_active_session((int)$user['id'], $newSessionId, $maxIdleSeconds);
|
||||
|
||||
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']);
|
||||
|
||||
+6
-2
@@ -6,13 +6,17 @@
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
start_secure_session();
|
||||
|
||||
// Vernietig persistente login in database
|
||||
// Vernietig persistente login en sessie in database (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
$uid = (int)$_SESSION['user_id'];
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_remember_token_columns($db);
|
||||
$stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmt->execute([$_SESSION['user_id']]);
|
||||
$stmt->execute([$uid]);
|
||||
|
||||
fociz_terminate_session(session_id());
|
||||
fociz_log_audit($uid, 'SESSION_TERMINATED_LOGOUT', 'users', $uid, ['ip' => fociz_get_client_ip()], 'SUCCESS', $_SESSION['user_email'] ?? null);
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren bij uitloggen
|
||||
}
|
||||
|
||||
+13
-1
@@ -62,15 +62,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$adminId = (int)$db->lastInsertId();
|
||||
|
||||
// Automatische dagsessie starten (geldig tot 23:59:59)
|
||||
// Automatische dagsessie starten met verse sessie ID (OWASP ASVS v4.0 §3.3.1)
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$newSessionId = session_id();
|
||||
|
||||
$_SESSION['user_id'] = $adminId;
|
||||
$_SESSION['user_name'] = $name;
|
||||
$_SESSION['user_email'] = $email;
|
||||
$_SESSION['user_role'] = 'ADMIN';
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
$_SESSION['auth_date'] = date('Y-m-d');
|
||||
$_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint();
|
||||
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
||||
$_SESSION['last_activity'] = time();
|
||||
$_SESSION['login_time'] = time();
|
||||
$_SESSION['last_rotated'] = time();
|
||||
clear_remember_cookie();
|
||||
issue_day_session_token($adminId);
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
fociz_register_active_session($adminId, $newSessionId, $maxIdleSeconds);
|
||||
unset($_SESSION['setup_totp_secret']);
|
||||
|
||||
fociz_log_audit($adminId, 'INITIAL_ADMIN_SETUP', 'user', $adminId, ['email' => $email, 'name' => $name], 'SUCCESS', $email);
|
||||
|
||||
@@ -293,3 +293,23 @@ CREATE TABLE IF NOT EXISTS `audit_logs` (
|
||||
INDEX (`action`),
|
||||
INDEX (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Fociz SOC 2 CC6.1 & ISO 27001 A.9.4.2: Actieve Sessietracking & Remote Revocation
|
||||
CREATE TABLE IF NOT EXISTS `user_sessions` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NOT NULL,
|
||||
`session_id_hash` VARCHAR(64) NOT NULL UNIQUE,
|
||||
`ip_address` VARCHAR(45) NOT NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`device_name` VARCHAR(100) NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`last_activity_at` DATETIME NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (`user_id`),
|
||||
INDEX (`session_id_hash`),
|
||||
INDEX (`expires_at`),
|
||||
INDEX (`is_active`),
|
||||
CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
+259
-6
@@ -295,15 +295,23 @@ function fociz_send_new_ip_verification(int $userId, string $email, string $name
|
||||
|
||||
function start_secure_session(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Dagsessie: 24 uur (86400s) zodat server-garbage collectors de sessie niet tussentijds wissen
|
||||
ini_set('session.gc_maxlifetime', '86400');
|
||||
ini_set('session.cookie_lifetime', '86400');
|
||||
$isSecure = is_https_request();
|
||||
|
||||
// OWASP ASVS v4.0 §3.4 & SOC 2 CC6.6 Cryptografische Hardening
|
||||
ini_set('session.sid_length', '48');
|
||||
ini_set('session.sid_bits_per_character', '6');
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.use_trans_sid', '0');
|
||||
ini_set('session.gc_maxlifetime', '86400');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
|
||||
// Cookie prefix: __Host- garandeert dat cookies niet door subdomeinen kunnen worden overschreven
|
||||
$sessionName = $isSecure ? '__Host-fociz_session' : 'fociz_session';
|
||||
session_name($sessionName);
|
||||
|
||||
$isSecure = is_https_request();
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 86400,
|
||||
'lifetime' => 0, // Browser sessiecookie: vervalt direct bij afsluiten browser conform ISO 27001 / NIST SP 800-63B
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => $isSecure,
|
||||
@@ -353,6 +361,230 @@ function ensure_remember_token_columns(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SOC 2 & ISO 27001 Actieve Sessietabel
|
||||
* Garandeert dat de tabel user_sessions aanwezig is voor multi-session tracking en remote revocation
|
||||
*/
|
||||
function ensure_sessions_table(PDO $db): void {
|
||||
static $sessionsTableChecked = false;
|
||||
if ($sessionsTableChecked) return;
|
||||
$sessionsTableChecked = true;
|
||||
|
||||
try {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS `user_sessions` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT NOT NULL,
|
||||
`session_id_hash` VARCHAR(64) NOT NULL UNIQUE,
|
||||
`ip_address` VARCHAR(45) NOT NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`device_name` VARCHAR(100) NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`last_activity_at` DATETIME NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (`user_id`),
|
||||
INDEX (`session_id_hash`),
|
||||
INDEX (`expires_at`),
|
||||
INDEX (`is_active`),
|
||||
CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
");
|
||||
} catch (Throwable $e) {
|
||||
error_log("ensure_sessions_table notice: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zet User-Agent om naar een herkenbare apparaatnaam (SOC 2 / ISO 27001 UI)
|
||||
*/
|
||||
function fociz_get_device_name(?string $userAgent): string {
|
||||
if (empty($userAgent)) return 'Onbekend apparaat';
|
||||
$ua = $userAgent;
|
||||
|
||||
$platform = 'Onbekend OS';
|
||||
if (stripos($ua, 'Windows NT 10.0') !== false) $platform = 'Windows 10/11';
|
||||
elseif (stripos($ua, 'Windows') !== false) $platform = 'Windows';
|
||||
elseif (stripos($ua, 'Macintosh') !== false || stripos($ua, 'Mac OS X') !== false) $platform = 'macOS';
|
||||
elseif (stripos($ua, 'iPhone') !== false) $platform = 'iPhone';
|
||||
elseif (stripos($ua, 'iPad') !== false) $platform = 'iPad';
|
||||
elseif (stripos($ua, 'Android') !== false) $platform = 'Android';
|
||||
elseif (stripos($ua, 'Linux') !== false) $platform = 'Linux';
|
||||
|
||||
$browser = 'Browser';
|
||||
if (stripos($ua, 'Edg/') !== false) $browser = 'Edge';
|
||||
elseif (stripos($ua, 'Chrome/') !== false && stripos($ua, 'Chromium') === false) $browser = 'Chrome';
|
||||
elseif (stripos($ua, 'Safari/') !== false && stripos($ua, 'Chrome') === false) $browser = 'Safari';
|
||||
elseif (stripos($ua, 'Firefox/') !== false) $browser = 'Firefox';
|
||||
elseif (stripos($ua, 'Opera') !== false || stripos($ua, 'OPR/') !== false) $browser = 'Opera';
|
||||
|
||||
return "{$browser} op {$platform}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Registreert of vernieuwt een actieve sessie in user_sessions (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
*/
|
||||
function fociz_register_active_session(int $userId, string $sessionId, int $maxIdleSeconds): void {
|
||||
try {
|
||||
if (empty($sessionId) || $userId <= 0) return;
|
||||
$db = get_db_connection();
|
||||
ensure_sessions_table($db);
|
||||
|
||||
$hash = hash('sha256', $sessionId);
|
||||
$ip = fociz_get_client_ip();
|
||||
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
|
||||
$deviceName = fociz_get_device_name($ua);
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + $maxIdleSeconds);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO user_sessions (user_id, session_id_hash, ip_address, user_agent, device_name, is_active, last_activity_at, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, NOW(), ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ip_address = VALUES(ip_address),
|
||||
user_agent = VALUES(user_agent),
|
||||
device_name = VALUES(device_name),
|
||||
is_active = 1,
|
||||
last_activity_at = NOW(),
|
||||
expires_at = VALUES(expires_at)
|
||||
");
|
||||
$stmt->execute([$userId, $hash, $ip, $ua, $deviceName, $expiresAt]);
|
||||
|
||||
// Verwijder oude verlopen sessies (> 7 dagen geleden verlopen)
|
||||
$db->exec("DELETE FROM user_sessions WHERE expires_at < NOW() - INTERVAL 7 DAY");
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_register_active_session error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Werkt de activiteitstijd en expiratiedatum bij van een actieve sessie
|
||||
*/
|
||||
function fociz_touch_session(string $sessionId, int $maxIdleSeconds): void {
|
||||
try {
|
||||
if (empty($sessionId)) return;
|
||||
$db = get_db_connection();
|
||||
$hash = hash('sha256', $sessionId);
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + $maxIdleSeconds);
|
||||
$stmt = $db->prepare("UPDATE user_sessions SET last_activity_at = NOW(), expires_at = ? WHERE session_id_hash = ? AND is_active = 1");
|
||||
$stmt->execute([$expiresAt, $hash]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_touch_session error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Roteert een sessie-ID in de database (OWASP ASVS v4.0 §3.3)
|
||||
*/
|
||||
function fociz_rotate_session(string $oldSessionId, string $newSessionId): void {
|
||||
try {
|
||||
if (empty($oldSessionId) || empty($newSessionId) || $oldSessionId === $newSessionId) return;
|
||||
$db = get_db_connection();
|
||||
$oldHash = hash('sha256', $oldSessionId);
|
||||
$newHash = hash('sha256', $newSessionId);
|
||||
$stmt = $db->prepare("UPDATE user_sessions SET session_id_hash = ? WHERE session_id_hash = ?");
|
||||
$stmt->execute([$newHash, $oldHash]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_rotate_session error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleert of de huidige sessie nog actief is (of ingetrokken op afstand)
|
||||
*/
|
||||
function fociz_is_session_active(string $sessionId, int $userId): bool {
|
||||
try {
|
||||
if (empty($sessionId) || $userId <= 0) return false;
|
||||
$db = get_db_connection();
|
||||
ensure_sessions_table($db);
|
||||
|
||||
$hash = hash('sha256', $sessionId);
|
||||
$stmt = $db->prepare("SELECT is_active, expires_at FROM user_sessions WHERE session_id_hash = ? AND user_id = ?");
|
||||
$stmt->execute([$hash, $userId]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) return true; // Als sessie nog niet geregistreerd staat, laat het door (wordt geregistreerd)
|
||||
return (!empty($row['is_active']));
|
||||
} catch (Throwable $e) {
|
||||
return true; // Fallback op toegestaan bij databaseverstoring
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Beëindigt een sessie in de database (bij uitloggen of time-out)
|
||||
*/
|
||||
function fociz_terminate_session(string $sessionId): void {
|
||||
try {
|
||||
if (empty($sessionId)) return;
|
||||
$db = get_db_connection();
|
||||
$hash = hash('sha256', $sessionId);
|
||||
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE session_id_hash = ?");
|
||||
$stmt->execute([$hash]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_terminate_session error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trekt alle overige sessies van een gebruiker in (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
*/
|
||||
function fociz_revoke_other_sessions(int $userId, string $currentSessionId): int {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_sessions_table($db);
|
||||
$currentHash = hash('sha256', $currentSessionId);
|
||||
|
||||
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE user_id = ? AND session_id_hash != ? AND is_active = 1");
|
||||
$stmt->execute([$userId, $currentHash]);
|
||||
$revokedCount = $stmt->rowCount();
|
||||
|
||||
// Invalideer ook het dagsessietoken in de database zodat herstel op andere browsers direct stopt
|
||||
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmtClear->execute([$userId]);
|
||||
|
||||
fociz_log_audit($userId, 'REVOKE_OTHER_SESSIONS', 'user_sessions', $userId, ['revoked_count' => $revokedCount], 'SUCCESS');
|
||||
return $revokedCount;
|
||||
} catch (Throwable $e) {
|
||||
error_log("fociz_revoke_other_sessions error: " . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trekt een specifieke sessie in op basis van ID
|
||||
*/
|
||||
function fociz_revoke_session_by_id(int $userId, int $sessionDbId): bool {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE id = ? AND user_id = ?");
|
||||
$stmt->execute([$sessionDbId, $userId]);
|
||||
fociz_log_audit($userId, 'SESSION_TERMINATED_REVOKED', 'user_sessions', $sessionDbId, ['session_db_id' => $sessionDbId], 'SUCCESS');
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt alle actieve sessies van een gebruiker op voor de profielpagina
|
||||
*/
|
||||
function fociz_get_user_active_sessions(int $userId, string $currentSessionId): array {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_sessions_table($db);
|
||||
$currentHash = hash('sha256', $currentSessionId);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, ip_address, user_agent, device_name, last_activity_at, created_at, (session_id_hash = ?) AS is_current
|
||||
FROM user_sessions
|
||||
WHERE user_id = ? AND is_active = 1
|
||||
ORDER BY is_current DESC, last_activity_at DESC
|
||||
");
|
||||
$stmt->execute([$currentHash, $userId]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Garandeert dat de kolom voor notificatie-instellingen aanwezig is in MariaDB
|
||||
*/
|
||||
@@ -621,10 +853,22 @@ function get_logged_in_user(): ?array {
|
||||
|
||||
// 2. Controleer actieve PHP sessie (mits behorend bij vandaag én hetzelfde IP/fingerprint)
|
||||
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
|
||||
// Controleer of sessie op afstand is ingetrokken (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
||||
if (!fociz_is_session_active(session_id(), (int)$_SESSION['user_id'])) {
|
||||
fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_TERMINATED_REVOKED', 'user_sessions', (int)$_SESSION['user_id'], ['reason' => 'remote_revocation'], 'WARNING');
|
||||
$_SESSION = ['session_was_expired' => true, 'session_revoked' => true];
|
||||
clear_day_session_cookie();
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_regenerate_id(true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dynamische inactiviteitstimeout conform NIST SP 800-63B / ISO 27001 / OWASP ASVS v4.0
|
||||
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
||||
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'], 'max_idle' => $maxIdleSeconds], 'WARNING');
|
||||
fociz_terminate_session(session_id());
|
||||
// Invalideer dagsessie token in database zodat herstel via cookie uitgesloten is
|
||||
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmtClear->execute([$_SESSION['user_id']]);
|
||||
@@ -640,6 +884,7 @@ function get_logged_in_user(): ?array {
|
||||
$maxSessionDuration = 36000;
|
||||
if (!empty($_SESSION['login_time']) && ($now - (int)$_SESSION['login_time']) > $maxSessionDuration) {
|
||||
fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_ABSOLUTE_TIMEOUT', 'users', (int)$_SESSION['user_id'], ['duration' => $now - (int)$_SESSION['login_time']], 'WARNING');
|
||||
fociz_terminate_session(session_id());
|
||||
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
||||
$stmtClear->execute([$_SESSION['user_id']]);
|
||||
$_SESSION = ['session_was_expired' => true, 'absolute_timeout' => true];
|
||||
@@ -668,8 +913,10 @@ function get_logged_in_user(): ?array {
|
||||
if (empty($_SESSION['last_rotated'])) {
|
||||
$_SESSION['last_rotated'] = $now;
|
||||
} elseif (($now - (int)$_SESSION['last_rotated']) > 1800) {
|
||||
$oldSid = session_id();
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_regenerate_id(true);
|
||||
fociz_rotate_session($oldSid, session_id());
|
||||
}
|
||||
$_SESSION['last_rotated'] = $now;
|
||||
}
|
||||
@@ -690,6 +937,7 @@ function get_logged_in_user(): ?array {
|
||||
try {
|
||||
$stmtAct = $db->prepare("UPDATE users SET last_activity_at = NOW() WHERE id = ?");
|
||||
$stmtAct->execute([$_SESSION['user_id']]);
|
||||
fociz_touch_session(session_id(), $maxIdleSeconds);
|
||||
} catch (Throwable $e) {}
|
||||
}
|
||||
}
|
||||
@@ -748,6 +996,8 @@ function get_logged_in_user(): ?array {
|
||||
$stmtAct->execute([$user['id']]);
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
fociz_register_active_session((int)$user['id'], session_id(), $maxIdleSeconds);
|
||||
|
||||
$user['avatar_url'] = fociz_get_valid_avatar_url($user['avatar_url'] ?? null, (int)$user['id']);
|
||||
$_SESSION['user_avatar_url'] = $user['avatar_url'];
|
||||
return $user;
|
||||
@@ -796,9 +1046,12 @@ function require_auth(bool $json = false): array {
|
||||
}
|
||||
$wasExpired = !empty($_SESSION['session_was_expired']);
|
||||
$ipChanged = !empty($_SESSION['ip_changed']);
|
||||
unset($_SESSION['session_was_expired'], $_SESSION['ip_changed']);
|
||||
$wasRevoked = !empty($_SESSION['session_revoked']);
|
||||
unset($_SESSION['session_was_expired'], $_SESSION['ip_changed'], $_SESSION['session_revoked']);
|
||||
if ($ipChanged) {
|
||||
header('Location: /auth/login.php?ip_changed=1');
|
||||
} elseif ($wasRevoked) {
|
||||
header('Location: /auth/login.php?revoked=1');
|
||||
} elseif ($wasExpired) {
|
||||
header('Location: /auth/login.php?expired=1');
|
||||
} else {
|
||||
|
||||
+104
-2
@@ -223,16 +223,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
} elseif ($newPassword !== $confirmPassword) {
|
||||
$message = "Het nieuwe wachtwoord en de bevestiging komen niet overeen.";
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
$stmtUp = $db->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
|
||||
$stmtUp->execute([$newHash, $userId]);
|
||||
|
||||
// SOC 2 CC6.1 / ISO 27001 A.9.4.2: Trek overige sessies in bij wachtwoordwijziging
|
||||
fociz_revoke_other_sessions($userId, session_id());
|
||||
|
||||
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. Alle overige sessies op andere apparaten zijn uit veiligheidsoverwegingen beëindigd.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
}
|
||||
|
||||
// 7b. Alle overige sessies op afstand beëindigen (SOC 2 / ISO 27001)
|
||||
elseif ($action === 'revoke_other_sessions') {
|
||||
$count = fociz_revoke_other_sessions($userId, session_id());
|
||||
$message = "Succesvol {$count} andere actieve sessie(s) op afstand beëindigd.";
|
||||
$messageType = 'success';
|
||||
}
|
||||
|
||||
// 8. Standaard avatar kiezen uit bibliotheek
|
||||
elseif ($action === 'select_preset_avatar') {
|
||||
$preset = trim($_POST['avatar_preset'] ?? '');
|
||||
@@ -331,6 +341,9 @@ $userAiRowSettings = json_decode($userAiIntRow['settings'] ?? '{}', true) ?: [];
|
||||
// Thema ophalen
|
||||
$userTheme = get_user_theme($userId);
|
||||
|
||||
// Actieve apparaten & sessies ophalen (SOC 2 / ISO 27001)
|
||||
$activeSessions = fociz_get_user_active_sessions($userId, session_id());
|
||||
|
||||
$pageTitle = 'Mijn Profiel & Koppelingen — Fociz';
|
||||
require_once __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
@@ -1255,6 +1268,72 @@ require_once __DIR__ . '/includes/header.php';
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actieve Apparaten & Sessies Card (SOC 2 CC6.1 / ISO 27001 A.9.4.2) -->
|
||||
<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 pb-5 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<div class="flex items-center space-x-3.5">
|
||||
<div class="w-12 h-12 rounded-2xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/20 flex items-center justify-center text-indigo-600 dark:text-indigo-400 font-bold shadow-xs flex-shrink-0">
|
||||
<i data-lucide="laptop" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Actieve Apparaten & Sessies</h3>
|
||||
<span class="px-2 py-0.5 rounded-full bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/60 text-emerald-700 dark:text-emerald-300 font-semibold text-[10px]">
|
||||
<?= count($activeSessions) ?> <?= count($activeSessions) === 1 ? 'actieve sessie' : 'actieve sessies' ?>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Centraal overzicht van alle actieve inlogapparaten conform ISO 27001 en SOC 2 richtlijnen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (count($activeSessions) > 1): ?>
|
||||
<form method="POST" action="/profile.php" onsubmit="return confirm('Weet u zeker dat u alle andere actieve sessies op afstand wilt beëindigen? U blijft op dit apparaat ingelogd.')">
|
||||
<input type="hidden" name="action" value="revoke_other_sessions">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
|
||||
<button type="submit" class="inline-flex items-center space-x-2 px-3.5 py-2 rounded-xl bg-rose-50 hover:bg-rose-100 dark:bg-rose-950/30 dark:hover:bg-rose-900/40 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/60 text-xs font-semibold transition cursor-pointer">
|
||||
<i data-lucide="log-out" class="w-3.5 h-3.5"></i>
|
||||
<span>Overal Uitloggen (Overige Sessies)</span>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5">
|
||||
<?php foreach ($activeSessions as $s): ?>
|
||||
<div class="p-3.5 rounded-2xl <?= $s['is_current'] ? 'bg-indigo-50/40 dark:bg-indigo-950/20 border-indigo-200/80 dark:border-indigo-800/50' : 'bg-slate-50/70 dark:bg-[#0c0f17]/60 border-slate-200 dark:border-slate-800' ?> border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-9 h-9 rounded-xl <?= $s['is_current'] ? 'bg-indigo-600 text-white shadow-xs' : 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400' ?> flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="<?= stripos($s['device_name'] ?? '', 'Phone') !== false || stripos($s['device_name'] ?? '', 'iPhone') !== false || stripos($s['device_name'] ?? '', 'Android') !== false ? 'smartphone' : 'monitor' ?>" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($s['device_name'] ?: 'Onbekend apparaat') ?></span>
|
||||
<?php if ($s['is_current']): ?>
|
||||
<span class="px-2 py-0.5 rounded-md bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800/60 text-[10px] font-bold">
|
||||
Dit apparaat (Huidig)
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 flex items-center space-x-2 mt-0.5">
|
||||
<span class="font-mono"><?= htmlspecialchars($s['ip_address']) ?></span>
|
||||
<span>•</span>
|
||||
<span>Laatst actief: <?= date('d-m-Y H:i', strtotime($s['last_activity_at'])) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!$s['is_current']): ?>
|
||||
<button type="button" onclick="revokeSessionById(<?= (int)$s['id'] ?>)" class="self-end sm:self-center px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-rose-50 dark:bg-slate-800 dark:hover:bg-rose-950/30 text-slate-600 hover:text-rose-600 dark:text-slate-400 dark:hover:text-rose-400 text-xs font-semibold transition border border-slate-200 dark:border-slate-700 hover:border-rose-200 dark:hover:border-rose-800 cursor-pointer">
|
||||
Sessie Beëindigen
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -1443,6 +1522,29 @@ async function testPersonalAiConnection() {
|
||||
btnText.innerText = 'Test Verbinding';
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSessionById(sessionId) {
|
||||
if (!confirm('Weet u zeker dat u deze sessie op afstand wilt beëindigen?')) return;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'revoke_session');
|
||||
formData.append('session_id', sessionId);
|
||||
formData.append('csrf_token', '<?= htmlspecialchars(get_csrf_token()) ?>');
|
||||
|
||||
const res = await fetch('/api/session.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Fout bij beëindigen van sessie.');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Netwerkfout bij beëindigen van sessie: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
@@ -81,6 +81,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
$stmtDelUser = $db->prepare("DELETE FROM users WHERE id = ?");
|
||||
$stmtDelUser->execute([$delUserId]);
|
||||
try {
|
||||
$db->prepare("DELETE FROM user_sessions WHERE user_id = ?")->execute([$delUserId]);
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
fociz_log_audit((int)$adminUser['id'], 'USER_DELETED', 'user', $delUserId, ['deleted_email' => $delUser['email'] ?? null, 'deleted_name' => $delUser['name'] ?? null], 'SUCCESS', $adminUser['email']);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user