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';
?>