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 */ function fociz_get_client_fingerprint(): string { $ip = fociz_get_client_ip(); $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; // Neem IP en User Agent mee voor strikte sessiebinding return hash('sha256', "{$ip}|{$ua}"); } /** * Controleert of een IP-adres bekend en geverifieerd is voor een gebruiker */ function fociz_is_ip_known_for_user(int $userId, string $ip): bool { try { $db = get_db_connection(); // Als de gebruiker nog NOOIT een IP heeft opgeslagen, beschouwen we dit als eerste setup $countStmt = $db->prepare("SELECT COUNT(*) FROM user_known_ips WHERE user_id = ?"); $countStmt->execute([$userId]); $totalKnown = (int)$countStmt->fetchColumn(); if ($totalKnown === 0) { return true; // Eerste inlog ooit } $stmt = $db->prepare("SELECT is_verified FROM user_known_ips WHERE user_id = ? AND ip_address = ?"); $stmt->execute([$userId, $ip]); $row = $stmt->fetch(); return ($row && !empty($row['is_verified'])); } catch (Throwable $e) { error_log("fociz_is_ip_known_for_user error: " . $e->getMessage()); return true; // Fallback op toegestaan bij databasefout } } /** * Registreert of vernieuwt een IP-adres voor een gebruiker */ function fociz_register_user_ip(int $userId, string $ip, bool $isVerified = true): void { try { $db = get_db_connection(); $ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255); $stmt = $db->prepare(" INSERT INTO user_known_ips (user_id, ip_address, user_agent, is_verified, last_seen_at) VALUES (?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE user_agent = VALUES(user_agent), is_verified = IF(VALUES(is_verified) = 1, 1, is_verified), last_seen_at = NOW() "); $stmt->execute([$userId, $ip, $ua, $isVerified ? 1 : 0]); // Werk ook last_login_ip en last_login_at bij op users tabel $updateUser = $db->prepare("UPDATE users SET last_login_ip = ?, last_login_at = NOW() WHERE id = ?"); $updateUser->execute([$ip, $userId]); } catch (Throwable $e) { error_log("fociz_register_user_ip error: " . $e->getMessage()); } } /** * Maakt een verificatietoken aan voor een nieuwe locatie/IP en stuurt een e-mail */ function fociz_send_new_ip_verification(int $userId, string $email, string $name, string $ip): bool { try { $db = get_db_connection(); $token = bin2hex(random_bytes(32)); $expiresAt = date('Y-m-d H:i:s', time() + 1800); // 30 minuten geldig $ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255); $stmt = $db->prepare(" INSERT INTO user_known_ips (user_id, ip_address, user_agent, is_verified, verify_token, verify_token_expires_at) VALUES (?, ?, ?, 0, ?, ?) ON DUPLICATE KEY UPDATE user_agent = VALUES(user_agent), verify_token = VALUES(verify_token), verify_token_expires_at = VALUES(verify_token_expires_at) "); $stmt->execute([$userId, $ip, $ua, $token, $expiresAt]); // Verificatie e-mail opstellen en verzenden require_once __DIR__ . '/mailer.php'; $appUrl = defined('APP_URL') ? APP_URL : 'https://www.fociz.nl'; $verifyUrl = "{$appUrl}/auth/verify-ip.php?token={$token}"; $cleanName = !empty($name) ? htmlspecialchars($name) : 'Collega'; $timeStr = date('d-m-Y H:i'); $contentHtml = "
Beste {$cleanName},
Er is zojuist geprobeerd in te loggen op uw Fociz-account vanaf een onbekende locatie of nieuw IP-adres:
| Nieuw IP-adres: | {$ip} |
| Tijdstip: | {$timeStr} |
Bent u dit zelf? Klik dan op onderstaande knop om deze locatie te autoriseren. Hierna kunt u direct veilig inloggen.
"; $html = FocizMailer::renderEmailTemplate([ 'title' => 'Nieuwe inloglocatie gedetecteerd', 'preheader' => "Inlogpoging vanaf nieuw IP {$ip} op Fociz", 'badge' => 'LOCATIE BEVEILIGING', 'content_html' => $contentHtml, 'cta_text' => 'Locatie Bevestigen & Toestaan', 'cta_url' => $verifyUrl, 'cta_subtext' => '⏱️ Deze autorisatielink is 30 minuten geldig.', 'security_notice' => 'Was u dit niet? Verander direct uw wachtwoord en neem contact op met uw systeembeheerder.' ]); $res = FocizMailer::send($email, $name, "Bevestig nieuwe inloglocatie ({$ip}) — Fociz", $html); return !empty($res['success']); } catch (Throwable $e) { error_log("fociz_send_new_ip_verification error: " . $e->getMessage()); return false; } } 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'); ini_set('session.use_strict_mode', '1'); ini_set('session.use_only_cookies', '1'); $isSecure = is_https_request(); session_set_cookie_params([ 'lifetime' => 86400, 'path' => '/', 'domain' => '', 'secure' => $isSecure, 'httponly' => true, 'samesite' => 'Lax' ]); session_start(); } } /** * Controleert of er al een beheerder bestaat in MariaDB */ function has_admin_user(): bool { try { $db = get_db_connection(); $stmt = $db->query("SELECT COUNT(*) FROM users WHERE role = 'ADMIN'"); return ((int)$stmt->fetchColumn()) > 0; } catch (Throwable $e) { return false; } } /** * Garandeert dat de kolommen voor persistent sessiebeheer aanwezig zijn in MariaDB */ function ensure_remember_token_columns(PDO $db): void { static $columnsChecked = false; if ($columnsChecked) return; $columnsChecked = true; try { $result = $db->query("SHOW COLUMNS FROM `users` LIKE 'remember_token'"); if (!$result || $result->rowCount() === 0) { $db->exec("ALTER TABLE `users` ADD COLUMN `remember_token` VARCHAR(64) NULL AFTER `backup_codes`, ADD COLUMN `remember_token_expires_at` DATETIME NULL AFTER `remember_token`"); } else { // Schoon uitsluitend verlopen tokens van gisteren op, wis NOOIT actieve tokens van vandaag $db->exec("UPDATE `users` SET `remember_token` = NULL, `remember_token_expires_at` = NULL WHERE `remember_token_expires_at` IS NOT NULL AND `remember_token_expires_at` < NOW()"); } $resActivity = $db->query("SHOW COLUMNS FROM `users` LIKE 'last_activity_at'"); if (!$resActivity || $resActivity->rowCount() === 0) { $db->exec("ALTER TABLE `users` ADD COLUMN `last_activity_at` DATETIME NULL AFTER `remember_token_expires_at`"); } } catch (Throwable $e) { // Stil negeren indien al aanwezig of tijdens migraties } } /** * Garandeert dat de kolom voor notificatie-instellingen aanwezig is in MariaDB */ function ensure_notification_columns(PDO $db): void { static $notifColumnsChecked = false; if ($notifColumnsChecked) return; $notifColumnsChecked = true; try { $result = $db->query("SHOW COLUMNS FROM `users` LIKE 'notification_settings'"); if (!$result || $result->rowCount() === 0) { $db->exec("ALTER TABLE `users` ADD COLUMN `notification_settings` TEXT NULL AFTER `remember_token_expires_at`"); } } catch (Throwable $e) { // Stil negeren indien al aanwezig } } /** * Garandeert dat de kolom voor weergavethema (dark, light, system) aanwezig is in MariaDB */ function ensure_theme_column(PDO $db): void { static $themeChecked = false; if ($themeChecked) return; $themeChecked = true; try { $result = $db->query("SHOW COLUMNS FROM `users` LIKE 'theme'"); if (!$result || $result->rowCount() === 0) { $db->exec("ALTER TABLE `users` ADD COLUMN `theme` VARCHAR(20) NOT NULL DEFAULT 'dark' AFTER `notification_settings`"); } } catch (Throwable $e) { // Stil negeren indien al aanwezig } } /** * Valideert en normaliseert een avatar URL. * Controleert of geüploade bestanden nog fysiek aanwezig zijn op de server (bijv. na container herstart). * Indien een bestand ontbreekt, wordt de database automatisch hersteld (self-healing) en null geretourneerd. */ function fociz_get_valid_avatar_url(?string $avatarUrl, ?int $userId = null): ?string { if (empty($avatarUrl)) { return null; } // 1. Data-URI (base64) of meegeleverde repository preset SVG: altijd persistent en geldig if (str_starts_with($avatarUrl, 'data:image/') || str_starts_with($avatarUrl, '/assets/avatars/')) { return $avatarUrl; } // 2. Geüpload bestand (/assets/uploads/...): controleer fysieke aanwezigheid op server if (str_starts_with($avatarUrl, '/assets/uploads/')) { $localPath = dirname(__DIR__) . '/' . ltrim($avatarUrl, '/'); if (file_exists($localPath)) { return $avatarUrl; } // Fysiek bestand ontbreekt (bijv. door container herstart of git deploy) if ($userId) { try { $db = get_db_connection(); $stmt = $db->prepare("UPDATE users SET avatar_url = NULL WHERE id = ? AND avatar_url = ?"); $stmt->execute([$userId, $avatarUrl]); if (isset($_SESSION['user_avatar_url']) && $_SESSION['user_avatar_url'] === $avatarUrl) { unset($_SESSION['user_avatar_url']); } } catch (Throwable $e) { error_log("Self-healing avatar cleanup notice: " . $e->getMessage()); } } return null; } return $avatarUrl; } /** * Verwerkt een geüploade avatar-afbeelding. * - Knipt vierkant uit en comprimeert naar max. 256x256 via GD * - Converteert naar Base64 Data URI voor 100% gegarandeerde persistentie in MariaDB * - Slaat tevens optioneel een backup op in assets/uploads/avatars/ */ function fociz_process_avatar_upload(array $file, int $userId): array { $allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if (!in_array($mime, $allowedTypes)) { return ['success' => false, 'error' => 'Alleen JPG, PNG, WebP of GIF afbeeldingen zijn toegestaan.']; } $ext = match($mime) { 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif', default => 'jpg' }; $rawContent = file_get_contents($file['tmp_name']); if ($rawContent === false || strlen($rawContent) === 0) { return ['success' => false, 'error' => 'Kon geüploade afbeelding niet inlezen.']; } $finalMime = $mime; $finalData = $rawContent; // Verklein en comprimeer naar max 256x256 vierkant via GD if (extension_loaded('gd') && function_exists('imagecreatefromstring')) { try { $srcImg = @imagecreatefromstring($rawContent); if ($srcImg !== false) { $w = imagesx($srcImg); $h = imagesy($srcImg); $maxDim = 256; $minDim = min($w, $h); $cropX = (int)(($w - $minDim) / 2); $cropY = (int)(($h - $minDim) / 2); $targetSize = min($maxDim, $minDim); $dstImg = imagecreatetruecolor($targetSize, $targetSize); imagealphablending($dstImg, false); imagesavealpha($dstImg, true); imagecopyresampled($dstImg, $srcImg, 0, 0, $cropX, $cropY, $targetSize, $targetSize, $minDim, $minDim); ob_start(); if ($mime === 'image/png') { imagepng($dstImg, null, 8); $finalMime = 'image/png'; } elseif (function_exists('imagewebp') && ($mime === 'image/webp' || $mime === 'image/jpeg')) { imagewebp($dstImg, null, 85); $finalMime = 'image/webp'; } else { imagejpeg($dstImg, null, 85); $finalMime = 'image/jpeg'; } $compressed = ob_get_clean(); if (!empty($compressed)) { $finalData = $compressed; } imagedestroy($dstImg); imagedestroy($srcImg); } } catch (Throwable $e) { error_log("fociz avatar resize notice: " . $e->getMessage()); } } // Bouw data URI voor permanente opslag in MariaDB $dataUri = 'data:' . $finalMime . ';base64,' . base64_encode($finalData); // Sla bestand ook lokaal op als backup / snelle cache $uploadDir = dirname(__DIR__) . '/assets/uploads/avatars'; if (!is_dir($uploadDir)) { @mkdir($uploadDir, 0755, true); } $filename = "avatar_{$userId}_" . time() . "_" . bin2hex(random_bytes(4)) . ".{$ext}"; @file_put_contents($uploadDir . '/' . $filename, $finalData); return [ 'success' => true, 'data_uri' => $dataUri, 'web_path' => "/assets/uploads/avatars/{$filename}" ]; } /** * Genereert een cryptografisch veilig dagsessie-token dat strikt geldig is tot 23:59:59 van vandaag. * Voorkomt dat gebruikers tussentijds worden uitgelogd bij idle timeouts of browserherstart. */ function issue_day_session_token(int $userId): string { clear_day_session_cookie(); try { $token = bin2hex(random_bytes(32)); $expiresAt = date('Y-m-d 23:59:59'); $secondsUntilMidnight = max(60, strtotime('today 23:59:59') - time()); $db = get_db_connection(); ensure_remember_token_columns($db); $stmt = $db->prepare("UPDATE users SET remember_token = ?, remember_token_expires_at = ?, last_activity_at = NOW() WHERE id = ?"); $stmt->execute([$token, $expiresAt, $userId]); $isSecure = is_https_request(); setcookie('fociz_day_session', $token, [ 'expires' => time() + $secondsUntilMidnight, 'path' => '/', 'secure' => $isSecure, 'httponly' => true, 'samesite' => 'Lax' ]); return $token; } catch (Throwable $e) { error_log("Fout bij aanmaken dagsessie token: " . $e->getMessage()); return ''; } } /** * Backward compatibility helper */ function issue_remember_token(int $userId): string { return issue_day_session_token($userId); } /** * Verwijdert de dagsessie cookies uit de browser */ function clear_day_session_cookie(): void { $isSecure = is_https_request(); foreach (['fociz_day_session', 'fociz_remember'] as $cookieName) { setcookie($cookieName, '', [ 'expires' => time() - 86400, 'path' => '/', 'secure' => $isSecure, 'httponly' => true, 'samesite' => 'Lax' ]); if (isset($_COOKIE[$cookieName])) { unset($_COOKIE[$cookieName]); } } } function clear_remember_cookie(): void { clear_day_session_cookie(); } /** * Haalt de huidige ingelogde gebruiker op uit de database. * Garandeert dat de sessie strikt tot 23:59:59 van vandaag actief blijft en morgen gegarandeerd verloopt. */ function get_logged_in_user(): ?array { start_secure_session(); $today = date('Y-m-d'); $now = time(); // Bepaal of dit een geautomatiseerde achtergrond poll is (zoals notifications, live sync of outlook polling) $isBackgroundPoll = !empty($_SERVER['HTTP_X_BACKGROUND_POLL']) || (isset($_SERVER['REQUEST_URI']) && ( strpos($_SERVER['REQUEST_URI'], '/api/live_sync.php') !== false || (strpos($_SERVER['REQUEST_URI'], '/api/notifications.php') !== false && ($_GET['action'] ?? '') === 'get_events') || (strpos($_SERVER['REQUEST_URI'], '/api/planner.php') !== false && ($_GET['action'] ?? '') === 'check_outlook_sync') )); // 1. Controleer of de huidige sessie van een eerdere dag is (dagwissel om middernacht) if (!empty($_SESSION['auth_date']) && $_SESSION['auth_date'] !== $today) { $_SESSION = ['session_was_expired' => true]; clear_day_session_cookie(); if (session_status() === PHP_SESSION_ACTIVE) { @session_regenerate_id(true); } return null; } $db = get_db_connection(); ensure_remember_token_columns($db); $clientFingerprint = fociz_get_client_fingerprint(); // 2. Controleer actieve PHP sessie (mits behorend bij vandaag én hetzelfde IP/fingerprint) if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) { // 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'); // 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']]); $_SESSION = ['session_was_expired' => true, 'idle_timeout' => true]; clear_day_session_cookie(); if (session_status() === PHP_SESSION_ACTIVE) { @session_regenerate_id(true); } return null; } // Absolute sessielimiet (NIST SP 800-63B): max 10 uur aaneengesloten sessie per dag $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'); $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]; 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 if (!empty($_SESSION['auth_fingerprint']) && $_SESSION['auth_fingerprint'] !== $clientFingerprint) { $_SESSION = ['session_was_expired' => true, 'ip_changed' => true]; clear_day_session_cookie(); if (session_status() === PHP_SESSION_ACTIVE) { @session_regenerate_id(true); } return null; } $stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, avatar_url, theme, notification_settings, default_jira_filter_code, fociz_widget_layout, created_at FROM users WHERE id = ?"); $stmt->execute([$_SESSION['user_id']]); $user = $stmt->fetch(); if ($user) { // Periodieke sessie-ID rotatie (OWASP ASVS v4.0 §3.3): elke 30 minuten ter voorkoming van session hijacking if (empty($_SESSION['last_rotated'])) { $_SESSION['last_rotated'] = $now; } elseif (($now - (int)$_SESSION['last_rotated']) > 1800) { if (session_status() === PHP_SESSION_ACTIVE) { @session_regenerate_id(true); } $_SESSION['last_rotated'] = $now; } if (empty($_SESSION['login_time'])) { $_SESSION['login_time'] = $now; } $_SESSION['auth_date'] = $today; $_SESSION['auth_fingerprint'] = $clientFingerprint; $_SESSION['session_expires_at'] = strtotime('today 23:59:59'); // Achtergrondpolls verversen last_activity NIET; alleen daadwerkelijke gebruikersinteractie houdt de sessie actief if (!$isBackgroundPoll) { $_SESSION['last_activity'] = $now; if (empty($_SESSION['last_db_activity_update']) || ($now - (int)$_SESSION['last_db_activity_update']) > 60) { $_SESSION['last_db_activity_update'] = $now; try { $stmtAct = $db->prepare("UPDATE users SET last_activity_at = NOW() WHERE id = ?"); $stmtAct->execute([$_SESSION['user_id']]); } catch (Throwable $e) {} } } $user['avatar_url'] = fociz_get_valid_avatar_url($user['avatar_url'] ?? null, (int)$user['id']); $_SESSION['user_avatar_url'] = $user['avatar_url']; return $user; } // Gebruiker bestaat niet meer $_SESSION = []; clear_day_session_cookie(); return null; } // 3. Fallback dagsessie-token: herstelt sessie na browserherstart (uitsluitend vandaag én mits niet inactief!) $dayToken = $_COOKIE['fociz_day_session'] ?? ($_COOKIE['fociz_remember'] ?? null); if (!empty($dayToken) && is_string($dayToken)) { $stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, avatar_url, theme, notification_settings, default_jira_filter_code, fociz_widget_layout, last_activity_at, last_login_at, created_at FROM users WHERE remember_token = ? AND remember_token_expires_at >= NOW() AND DATE(remember_token_expires_at) = ?"); $stmt->execute([$dayToken, $today]); $user = $stmt->fetch(); if ($user) { $maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800; // Controleer inactiviteit: als gebruiker te lang inactief is geweest, herstel weigeren en opnieuw inloggen $lastActTime = !empty($user['last_activity_at']) ? strtotime($user['last_activity_at']) : (!empty($user['last_login_at']) ? strtotime($user['last_login_at']) : 0); if ($lastActTime > 0 && ($now - $lastActTime) > $maxIdleSeconds) { fociz_log_audit((int)$user['id'], 'SESSION_IDLE_TIMEOUT_COOKIE', 'users', (int)$user['id'], ['idle_seconds' => $now - $lastActTime, 'max_idle' => $maxIdleSeconds], 'WARNING'); $stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?"); $stmtClear->execute([$user['id']]); $_SESSION = ['session_was_expired' => true, 'idle_timeout' => true]; clear_day_session_cookie(); return null; } $currentIp = fociz_get_client_ip(); // Als het token op een ander, onbekend IP wordt aangeboden, weiger de automatische herstel if (!fociz_is_ip_known_for_user((int)$user['id'], $currentIp)) { clear_day_session_cookie(); return null; } // Herstel sessie voor vandaag $_SESSION['user_id'] = $user['id']; $_SESSION['user_name'] = $user['name']; $_SESSION['user_email'] = $user['email']; $_SESSION['user_role'] = $user['role']; $_SESSION['is_2fa_verified'] = true; $_SESSION['auth_date'] = $today; $_SESSION['auth_fingerprint'] = $clientFingerprint; $_SESSION['session_expires_at'] = strtotime('today 23:59:59'); $_SESSION['last_activity'] = $now; $_SESSION['login_time'] = $now; $_SESSION['last_rotated'] = $now; $_SESSION['last_db_activity_update'] = $now; try { $stmtAct = $db->prepare("UPDATE users SET last_activity_at = NOW() WHERE id = ?"); $stmtAct->execute([$user['id']]); } catch (Throwable $e) {} $user['avatar_url'] = fociz_get_valid_avatar_url($user['avatar_url'] ?? null, (int)$user['id']); $_SESSION['user_avatar_url'] = $user['avatar_url']; return $user; } // Token is ongeldig of van gisteren clear_day_session_cookie(); } return null; } /** * Beschermt pagina's: dwingt login + 2FA af */ function require_auth(bool $json = false): array { start_secure_session(); $requestUri = $_SERVER['REQUEST_URI'] ?? ''; $scriptName = $_SERVER['SCRIPT_NAME'] ?? ''; $isApiCall = str_contains($requestUri, '/api/') || str_contains($scriptName, '/api/'); $isAjax = $json || $isApiCall || (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) || (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'); // 1. Controleer of eerste setup nodig is if (!has_admin_user()) { if ($isAjax) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Setup vereist']); exit; } header('Location: /auth/setup.php'); exit; } // 2. Controleer of gebruiker ingelogd én 2FA geverifieerd is voor vandaag $user = get_logged_in_user(); if (!$user) { if ($isAjax) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Niet geauthenticeerd of sessie verlopen']); exit; } $wasExpired = !empty($_SESSION['session_was_expired']); $ipChanged = !empty($_SESSION['ip_changed']); unset($_SESSION['session_was_expired'], $_SESSION['ip_changed']); if ($ipChanged) { header('Location: /auth/login.php?ip_changed=1'); } elseif ($wasExpired) { header('Location: /auth/login.php?expired=1'); } else { header('Location: /auth/login.php'); } exit; } return $user; } /** * Dwingt beheerder (ADMIN) rol af */ function require_admin(): array { $user = require_auth(); if ($user['role'] !== 'ADMIN') { header('Location: /planner.php'); exit; } return $user; } /** * Maakt een nieuwe uitnodiging aan voor een collega */ function create_invite(int $createdById, string $email, string $role = 'MEMBER'): string { $db = get_db_connection(); $token = bin2hex(random_bytes(24)); $expiresAt = date('Y-m-d H:i:s', strtotime('+7 days')); $stmt = $db->prepare("INSERT INTO invites (token, email, role, expires_at, created_by_id) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$token, strtolower(trim($email)), $role, $expiresAt, $createdById]); return $token; } /** * Valideert een invite token */ function validate_invite(string $token): ?array { $db = get_db_connection(); $stmt = $db->prepare(" SELECT i.*, u.name as inviter_name, u.email as inviter_email FROM invites i JOIN users u ON i.created_by_id = u.id WHERE i.token = ? "); $stmt->execute([$token]); $invite = $stmt->fetch(); if (!$invite) return null; if ($invite['used_at'] !== null) return null; if (strtotime($invite['expires_at']) < time()) return null; return $invite; } /** * CSRF token helpers */ function get_csrf_token(): string { start_secure_session(); if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } return $_SESSION['csrf_token']; } function verify_csrf_token(?string $token): bool { start_secure_session(); return !empty($token) && !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token); } /** * Rate limiting helpers via app_cache */ function fociz_check_rate_limit(string $actionKey, int $maxAttempts = 5, int $decaySeconds = 900): array { require_once __DIR__ . '/cache.php'; $cacheKey = 'rl_' . hash('sha256', $actionKey); $data = fociz_cache_get($cacheKey); $attempts = 0; $expiresAt = time() + $decaySeconds; if (is_array($data)) { $attempts = (int)($data['attempts'] ?? 0); $expiresAt = (int)($data['expires_at'] ?? $expiresAt); } $allowed = ($attempts < $maxAttempts); $remaining = max(0, $maxAttempts - $attempts); $retryAfter = max(0, $expiresAt - time()); return [ 'allowed' => $allowed, 'remaining' => $remaining, 'retry_after' => $retryAfter, 'attempts' => $attempts ]; } function fociz_hit_rate_limit(string $actionKey, int $decaySeconds = 900): void { require_once __DIR__ . '/cache.php'; $cacheKey = 'rl_' . hash('sha256', $actionKey); $data = fociz_cache_get($cacheKey); $attempts = 1; $expiresAt = time() + $decaySeconds; if (is_array($data)) { $attempts = (int)($data['attempts'] ?? 0) + 1; $expiresAt = (int)($data['expires_at'] ?? $expiresAt); } $ttl = max(1, $expiresAt - time()); fociz_cache_set($cacheKey, ['attempts' => $attempts, 'expires_at' => $expiresAt], $ttl); } function fociz_clear_rate_limit(string $actionKey): void { require_once __DIR__ . '/cache.php'; $cacheKey = 'rl_' . hash('sha256', $actionKey); fociz_cache_delete($cacheKey); } /** * Genereert een cryptografisch ondertekende OAuth state parameter waarin de user_id en timestamp veilig zijn opgeslagen. * Dit voorkomt CSRF en maakt sessieherstel mogelijk bij cross-site OAuth redirects. */ function generate_oauth_state(int $userId): string { $secret = defined('APP_SECRET') ? APP_SECRET : (defined('DB_PASS') ? DB_PASS : 'fociz_oauth_salt_2026'); $payload = [ 'uid' => $userId, 'ts' => time(), 'rnd' => bin2hex(random_bytes(8)) ]; $json = json_encode($payload); $b64 = rtrim(strtr(base64_encode($json), '+/', '-_'), '='); $sig = hash_hmac('sha256', $b64, $secret); return $b64 . '.' . $sig; } /** * Valideert een ontvangen OAuth state parameter en geeft bij succes het geverifieerde user_id terug. * Geeft null terug bij ongeldige handtekening of verlopen state (> 30 minuten). */ function verify_oauth_state(string $state): ?int { $secret = defined('APP_SECRET') ? APP_SECRET : (defined('DB_PASS') ? DB_PASS : 'fociz_oauth_salt_2026'); $parts = explode('.', $state, 2); if (count($parts) !== 2) { return null; } [$b64, $sig] = $parts; $expectedSig = hash_hmac('sha256', $b64, $secret); if (!hash_equals($expectedSig, $sig)) { return null; } $remainder = strlen($b64) % 4; if ($remainder) { $b64 .= str_repeat('=', 4 - $remainder); } $json = base64_decode(strtr($b64, '-_', '+/')); $payload = json_decode($json, true); if (!is_array($payload) || empty($payload['uid']) || empty($payload['ts'])) { return null; } // Geldigheidsduur: maximaal 30 minuten (1800 seconden) if (time() - (int)$payload['ts'] > 1800) { return null; } return (int)$payload['uid']; } /** * Haalt de persoonlijke notificatie-instellingen van een gebruiker op */ function get_user_notification_settings(int $userId): array { $db = get_db_connection(); ensure_notification_columns($db); $defaults = [ 'enabled' => 1, 'lead_time_minutes' => 5, // 0, 2, 5, 10, 15, 30 'repeat_count' => 2, // 1, 2, 3, 5 'repeat_interval_minutes' => 2, // 1, 2, 3, 5 'sound_enabled' => 1, // 0 of 1 ]; try { $stmt = $db->prepare("SELECT notification_settings FROM users WHERE id = ?"); $stmt->execute([$userId]); $json = $stmt->fetchColumn(); if (!empty($json)) { $saved = json_decode($json, true); if (is_array($saved)) { return array_merge($defaults, $saved); } } } catch (Throwable $e) { error_log("get_user_notification_settings error: " . $e->getMessage()); } return $defaults; } /** * Slaat de persoonlijke notificatie-instellingen van een gebruiker op */ function update_user_notification_settings(int $userId, array $settings): bool { $db = get_db_connection(); ensure_notification_columns($db); $current = get_user_notification_settings($userId); $merged = [ 'enabled' => !empty($settings['enabled']) ? 1 : 0, 'lead_time_minutes' => max(0, min(60, (int)($settings['lead_time_minutes'] ?? $current['lead_time_minutes']))), 'repeat_count' => max(1, min(10, (int)($settings['repeat_count'] ?? $current['repeat_count']))), 'repeat_interval_minutes' => max(1, min(30, (int)($settings['repeat_interval_minutes'] ?? $current['repeat_interval_minutes']))), 'sound_enabled' => !empty($settings['sound_enabled']) ? 1 : 0, ]; try { $stmt = $db->prepare("UPDATE users SET notification_settings = ? WHERE id = ?"); return $stmt->execute([json_encode($merged), $userId]); } catch (Throwable $e) { error_log("update_user_notification_settings error: " . $e->getMessage()); return false; } } /** * Haalt het voorkeursthema op voor een gebruiker ('dark', 'light' of 'system') */ function get_user_theme(int $userId): string { global $currentUser; if (!empty($currentUser['id']) && (int)$currentUser['id'] === $userId && !empty($currentUser['theme'])) { return $currentUser['theme']; } try { $db = get_db_connection(); $stmt = $db->prepare("SELECT theme FROM users WHERE id = ?"); $stmt->execute([$userId]); $theme = $stmt->fetchColumn(); if ($theme && in_array($theme, ['dark', 'light', 'system'])) { return $theme; } } catch (Throwable $e) { error_log("get_user_theme error: " . $e->getMessage()); } return 'dark'; } /** * Slaat het voorkeursthema van een gebruiker op ('dark', 'light' of 'system') */ function update_user_theme(int $userId, string $theme): bool { $db = get_db_connection(); ensure_theme_column($db); $theme = in_array($theme, ['dark', 'light', 'system']) ? $theme : 'dark'; try { $stmt = $db->prepare("UPDATE users SET theme = ? WHERE id = ?"); return $stmt->execute([$theme, $userId]); } catch (Throwable $e) { error_log("update_user_theme error: " . $e->getMessage()); return false; } }