diff --git a/.gitignore b/.gitignore index 01950e8..0e388cb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,5 @@ Thumbs.db *.log .vscode/ .idea/ -config/config.php -config/.env.example \ No newline at end of file +config/.env.example +config/config.php \ No newline at end of file diff --git a/auth/callback_microsoft.php b/auth/callback_microsoft.php index 2e5ac98..ce8f349 100644 --- a/auth/callback_microsoft.php +++ b/auth/callback_microsoft.php @@ -7,22 +7,54 @@ require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/integrations/microsoft.php'; -$user = require_auth(); +start_secure_session(); + +// 1. CSRF State validatie & Sessieherstel +$receivedState = $_GET['state'] ?? ''; +$stateUserId = !empty($receivedState) ? verify_oauth_state($receivedState) : null; + +// Als de sessie verloren is gegaan tijdens de externe cross-site redirect vanaf Microsoft, +// herstellen we de gebruiker veilig aan de hand van de cryptografisch ondertekende OAuth state! +if (empty($_SESSION['user_id']) && $stateUserId) { + try { + $db = get_db_connection(); + $stmtUser = $db->prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?"); + $stmtUser->execute([$stateUserId]); + $recoveredUser = $stmtUser->fetch(); + if ($recoveredUser) { + $_SESSION['user_id'] = $recoveredUser['id']; + $_SESSION['user_name'] = $recoveredUser['name']; + $_SESSION['user_email'] = $recoveredUser['email']; + $_SESSION['user_role'] = $recoveredUser['role']; + $_SESSION['is_2fa_verified'] = true; + issue_remember_token((int)$recoveredUser['id']); + } + } catch (Throwable $e) { + // Foutafhandeling + } +} + +// 2. Controleer authenticatie +$user = get_logged_in_user(); +if (!$user) { + header('Location: /auth/login.php?error=' . urlencode('Sessie verlopen tijdens Microsoft koppeling. Log opnieuw in en probeer het nogmaals.')); + exit; +} + $db = get_db_connection(); -// 1. Foutafhandeling vanuit Microsoft +// 3. Foutafhandeling vanuit Microsoft (bijv. gebruiker weigerde machtiging) if (!empty($_GET['error'])) { $errorDescription = $_GET['error_description'] ?? $_GET['error']; header('Location: /integrations.php?error=' . urlencode("Microsoft melding: {$errorDescription}")); exit; } -// 2. CSRF State validatie -$receivedState = $_GET['state'] ?? ''; +// 4. Valideer de state (via HMAC handtekening of fallback sessiestate) $expectedState = $_SESSION['ms_oauth_state'] ?? ''; -unset($_SESSION['ms_oauth_state']); +unset($_SESSION['ms_oauth_state'], $_SESSION['ms_oauth_user_id']); -if (empty($receivedState) || !hash_equals($expectedState, $receivedState)) { +if (!$stateUserId && (empty($receivedState) || empty($expectedState) || !hash_equals($expectedState, $receivedState))) { header('Location: /integrations.php?error=' . urlencode('Ongeldige sessie of CSRF-controle mislukt. Probeer opnieuw in te loggen via Microsoft.')); exit; } diff --git a/auth/invite.php b/auth/invite.php index ed8d8d3..0c035e6 100644 --- a/auth/invite.php +++ b/auth/invite.php @@ -81,6 +81,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $_SESSION['user_email'] = $invite['email']; $_SESSION['user_role'] = $invite['role']; $_SESSION['is_2fa_verified'] = true; + issue_remember_token($newUserId); unset($_SESSION['invite_totp_secret']); header('Location: /planner.php'); diff --git a/auth/login.php b/auth/login.php index 60e4328..b866bf7 100644 --- a/auth/login.php +++ b/auth/login.php @@ -12,8 +12,9 @@ if (!has_admin_user()) { exit; } -// Als al volledig ingelogd en 2FA geverifieerd, ga naar planner -if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) { +// Als al ingelogd (via actieve sessie of persistente cookie), ga direct naar planner +$currentUser = get_logged_in_user(); +if ($currentUser) { header('Location: /planner.php'); exit; } @@ -96,6 +97,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $_SESSION['user_role'] = $user['role']; $_SESSION['is_2fa_verified'] = true; + // Activeer persistente remember token (30 dagen blijf ingelogd) + issue_remember_token((int)$user['id']); + unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']); header('Location: /planner.php'); diff --git a/auth/logout.php b/auth/logout.php index ab8d885..c1ef092 100644 --- a/auth/logout.php +++ b/auth/logout.php @@ -6,6 +6,21 @@ require_once __DIR__ . '/../includes/auth.php'; start_secure_session(); +// Vernietig persistente login in database +if (!empty($_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']]); + } catch (Throwable $e) { + // Stil negeren bij uitloggen + } +} + +// Verwijder remember cookie +clear_remember_cookie(); + // Vernietig alle sessie variabelen $_SESSION = []; diff --git a/auth/oauth_microsoft.php b/auth/oauth_microsoft.php index b0848c3..17879f9 100644 --- a/auth/oauth_microsoft.php +++ b/auth/oauth_microsoft.php @@ -31,10 +31,10 @@ if (empty($clientId)) { // Bepaal de dynamische of geconfigureerde redirect URI $redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php'; -// Genereer unieke CSRF state -$state = bin2hex(random_bytes(16)); +// Genereer cryptografisch ondertekende OAuth state waarin de user_id en timestamp veilig zijn opgeslagen +$state = generate_oauth_state((int)$user['id']); $_SESSION['ms_oauth_state'] = $state; -$_SESSION['ms_oauth_user_id'] = $user['id']; +$_SESSION['ms_oauth_user_id'] = (int)$user['id']; // Stuur gebruiker door naar Microsoft login $authUrl = microsoft_get_auth_url($clientId, $redirectUri, $tenantId, $state); diff --git a/auth/setup.php b/auth/setup.php index 47637f5..4821e74 100644 --- a/auth/setup.php +++ b/auth/setup.php @@ -68,6 +68,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $_SESSION['user_email'] = $email; $_SESSION['user_role'] = 'ADMIN'; $_SESSION['is_2fa_verified'] = true; + issue_remember_token($adminId); unset($_SESSION['setup_totp_secret']); $success = true; diff --git a/brainstorm.php b/brainstorm.php index e0f6a73..ead47d5 100644 --- a/brainstorm.php +++ b/brainstorm.php @@ -3,7 +3,8 @@ * Fociz Brainstorm Sessies Overzicht */ -require_once __DIR__ . '/includes/header.php'; +require_once __DIR__ . '/includes/auth.php'; +$currentUser = require_auth(); $db = get_db_connection(); // Verwerk nieuwe sessie aanmaken @@ -36,6 +37,7 @@ $stmtSessions = $db->query(" ORDER BY s.created_at DESC "); $sessions = $stmtSessions->fetchAll(); +require_once __DIR__ . '/includes/header.php'; ?>
diff --git a/config/.env.example b/config/.env.example index 34771c8..d2a9463 100644 --- a/config/.env.example +++ b/config/.env.example @@ -1,15 +1,15 @@ # MariaDB Verbinding DB_HOST="192.168.2.33" DB_PORT="3306" -DB_NAME="fociz" -DB_USER="focizlive_user" -DB_PASS="F0c1z_Live_Secure_2026!" +DB_NAME="focizdev" +DB_USER="focizdev_user" +DB_PASS="F0c1z_Dev_Secure_2026!" # Omgeving: 'development' (knippert rood) of 'live' (knippert groen) -APP_ENV="live" +APP_ENV="development" -# Applicatie URL (voor uitnodigingslinks en OAuth redirects: https://fociz.nl) -APP_URL="https://fociz.nl" +# Applicatie URL (voor uitnodigingslinks en OAuth redirects: https://dev.fociz.nl of https://fociz.nl) +APP_URL="https://dev.fociz.nl" # Applicatie Geheime Sleutel (willekeurige tekenreeks) APP_SECRET="fociz-maria-db-super-secret-key-998877" @@ -23,3 +23,4 @@ MICROSOFT_TENANT_ID="common" JIRA_DOMAIN="" JIRA_EMAIL="" JIRA_API_TOKEN="" + diff --git a/config/config.php b/config/config.php index 2d376a4..02a2c7a 100644 --- a/config/config.php +++ b/config/config.php @@ -1,6 +1,6 @@ 7 * 24 * 60 * 60, // 7 dagen - 'path' => '/', - 'secure' => $isSecure, + 'lifetime' => 30 * 24 * 60 * 60, // 30 dagen + 'path' => '/', + 'domain' => '', + 'secure' => $isSecure, 'httponly' => true, 'samesite' => 'Lax' ]); @@ -35,31 +63,136 @@ function has_admin_user(): bool { } /** - * Haalt de huidige ingelogde gebruiker op uit de database + * 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`"); + } + } catch (Throwable $e) { + // Stil negeren indien al aanwezig of tijdens migraties + } +} + +/** + * Genereert en bewaart een persistente 'Onthoud mij' token in MariaDB en plaatst een veilige cookie + */ +function issue_remember_token(int $userId): string { + $rawToken = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $rawToken); + $expiresAt = date('Y-m-d H:i:s', time() + (30 * 86400)); // 30 dagen geldig + + $db = get_db_connection(); + ensure_remember_token_columns($db); + + $stmt = $db->prepare("UPDATE users SET remember_token = ?, remember_token_expires_at = ? WHERE id = ?"); + $stmt->execute([$tokenHash, $expiresAt, $userId]); + + $isSecure = is_https_request(); + setcookie('fociz_remember', $rawToken, [ + 'expires' => time() + (30 * 86400), + 'path' => '/', + 'secure' => $isSecure, + 'httponly' => true, + 'samesite' => 'Lax' + ]); + + return $rawToken; +} + +/** + * Verwijdert de persistente remember cookie + */ +function clear_remember_cookie(): void { + $isSecure = is_https_request(); + setcookie('fociz_remember', '', [ + 'expires' => time() - 86400, + 'path' => '/', + 'secure' => $isSecure, + 'httponly' => true, + 'samesite' => 'Lax' + ]); +} + +/** + * Haalt de huidige ingelogde gebruiker op uit de database. + * Herstelt automatisch de sessie als een geldige 'fociz_remember' cookie aanwezig is. */ function get_logged_in_user(): ?array { start_secure_session(); - if (empty($_SESSION['user_id']) || empty($_SESSION['is_2fa_verified'])) { - return null; + $db = get_db_connection(); + ensure_remember_token_columns($db); + + // 1. Controleer actieve PHP sessie + if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) { + $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) { + return $user; + } + // Gebruiker bestaat niet meer in database + $_SESSION = []; } - $db = get_db_connection(); - $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(); + // 2. Controleer persistente 'remember me' cookie (voorkomt ongewenst uitloggen bij refresh of herstart) + if (!empty($_COOKIE['fociz_remember'])) { + $rawToken = trim($_COOKIE['fociz_remember']); + $tokenHash = hash('sha256', $rawToken); - return $user ?: null; + $stmt = $db->prepare(" + SELECT id, name, email, role, two_factor_enabled, created_at + FROM users + WHERE remember_token = ? AND remember_token_expires_at > NOW() + "); + $stmt->execute([$tokenHash]); + $user = $stmt->fetch(); + + if ($user) { + // Herstel volledige PHP sessie + $_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; + + // Verleng direct de geldigheid van de remember token voor nog eens 30 dagen + issue_remember_token((int)$user['id']); + + return $user; + } else { + clear_remember_cookie(); + } + } + + return null; } /** * Beschermt pagina's: dwingt login + 2FA af */ -function require_auth(): array { +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; } @@ -67,6 +200,12 @@ function require_auth(): array { // 2. Controleer of gebruiker ingelogd én 2FA geverifieerd is $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; + } header('Location: /auth/login.php'); exit; } @@ -137,3 +276,52 @@ function verify_csrf_token(?string $token): bool { 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']; +} + + diff --git a/includes/header.php b/includes/header.php index ebc807a..cf48a6c 100644 --- a/includes/header.php +++ b/includes/header.php @@ -4,7 +4,7 @@ */ require_once __DIR__ . '/auth.php'; -$currentUser = $currentUser ?? get_logged_in_user(); +$currentUser = $currentUser ?? require_auth(); $currentPage = basename($_SERVER['PHP_SELF']); ?> diff --git a/index.php b/index.php index 424938f..d30943a 100644 --- a/index.php +++ b/index.php @@ -11,7 +11,8 @@ if (!has_admin_user()) { exit; } -if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) { +$user = get_logged_in_user(); +if ($user) { header('Location: /planner.php'); exit; } diff --git a/users.php b/users.php index 15f3b7d..0f38817 100644 --- a/users.php +++ b/users.php @@ -3,8 +3,9 @@ * Fociz Team & Uitnodigingen Beheer (Enkel voor Admin) */ -require_once __DIR__ . '/includes/header.php'; +require_once __DIR__ . '/includes/auth.php'; $adminUser = require_admin(); +$currentUser = $adminUser; $db = get_db_connection(); $message = null; @@ -63,6 +64,8 @@ $invites = $db->query(" JOIN users u ON i.created_by_id = u.id ORDER BY i.created_at DESC ")->fetchAll(); + +require_once __DIR__ . '/includes/header.php'; ?>