$secondsUntilMidnight, // Verloopt vannacht om 23:59:59 '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 { // Maak alle bestaande actieve tokens direct leeg zodat geen enkele oude persistente browser-sessie blijft bestaan $db->exec("UPDATE `users` SET `remember_token` = NULL, `remember_token_expires_at` = NULL WHERE `remember_token` IS NOT NULL"); } } 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 } } /** * Verouderde functie voor persistent login. * Bewust geneutraliseerd: cookies worden direct gewist en geen tokens meer aangemaakt, * zodat gebruikers iedere dag om 23:59:59 worden uitgelogd en daadwerkelijk opnieuw moeten inloggen. */ function issue_remember_token(int $userId): string { clear_remember_cookie(); 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([$userId]); } catch (Throwable $e) {} return ''; } /** * Verwijdert de persistente remember cookie uit de browser */ function clear_remember_cookie(): void { $isSecure = is_https_request(); setcookie('fociz_remember', '', [ 'expires' => time() - 86400, 'path' => '/', 'secure' => $isSecure, 'httponly' => true, 'samesite' => 'Lax' ]); if (isset($_COOKIE['fociz_remember'])) { unset($_COOKIE['fociz_remember']); } } /** * Haalt de huidige ingelogde gebruiker op uit de database. * Garandeert dat de sessie strikt om 23:59:59 gereset wordt en nooit meerdere dagen blijft staan. * Bevat GEEN persistente cookies of auto-login bypass: de gebruiker moet iedere dag daadwerkelijk opnieuw inloggen. */ function get_logged_in_user(): ?array { start_secure_session(); $today = date('Y-m-d'); $now = time(); // Verwijder altijd eventuele oude of achtergebleven fociz_remember cookies if (isset($_COOKIE['fociz_remember'])) { clear_remember_cookie(); } // Controleer of de sessie van een eerdere dag is of voorbij 23:59:59 $isExpired = false; if (!empty($_SESSION['auth_date']) && $_SESSION['auth_date'] !== $today) { $isExpired = true; } if (!empty($_SESSION['session_expires_at']) && $now > (int)$_SESSION['session_expires_at']) { $isExpired = true; } if ($isExpired) { $_SESSION = ['session_was_expired' => true]; clear_remember_cookie(); if (session_status() === PHP_SESSION_ACTIVE) { @session_regenerate_id(true); } return null; } // Controleer actieve PHP sessie (uitsluitend de sessie van vandaag is geldig) if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) { $db = get_db_connection(); ensure_remember_token_columns($db); $stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?"); $stmt->execute([$_SESSION['user_id']]); $user = $stmt->fetch(); if ($user) { $_SESSION['auth_date'] = $today; $_SESSION['session_expires_at'] = strtotime('today 23:59:59'); return $user; } // Gebruiker bestaat niet meer in database $_SESSION = []; clear_remember_cookie(); } return null; } /** * Beschermt pagina's: dwingt login + 2FA af */ function require_auth(bool $json = false): array { start_secure_session(); $isAjax = $json || (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']); unset($_SESSION['session_was_expired']); if ($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); } /** * 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 { $db = get_db_connection(); ensure_theme_column($db); try { $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; } }