72ad1fb70e
feat(microsoft-integration): enhance inbox and events fetching with caching support feat(auth): include theme and notification settings in user data retrieval feat(profile): improve password visibility toggle functionality feat(planner): optimize event synchronization with Outlook using caching fix(notifications): update mail badge handling for mobile and desktop
513 lines
17 KiB
PHP
513 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* Authenticatie & Autorisatie Guard voor Fociz
|
|
*/
|
|
|
|
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === 'auth.php') {
|
|
http_response_code(403);
|
|
exit('Toegang geweigerd.');
|
|
}
|
|
|
|
require_once __DIR__ . '/../config/database.php';
|
|
require_once __DIR__ . '/totp.php';
|
|
|
|
/**
|
|
* Bepaalt of de huidige verbinding HTTPS gebruikt (inclusief reverse proxies / OpenResty / Docker)
|
|
*/
|
|
function is_https_request(): bool {
|
|
if (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') {
|
|
return true;
|
|
}
|
|
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') {
|
|
return true;
|
|
}
|
|
if (!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && strtolower($_SERVER['HTTP_X_FORWARDED_SSL']) === 'on') {
|
|
return true;
|
|
}
|
|
if (isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443) {
|
|
return true;
|
|
}
|
|
if (defined('APP_URL') && str_starts_with(APP_URL, 'https://')) {
|
|
return true;
|
|
}
|
|
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()");
|
|
}
|
|
} 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = ? 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();
|
|
|
|
// 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);
|
|
|
|
// 2. Controleer actieve PHP sessie (mits behorend bij vandaag)
|
|
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
|
|
$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) {
|
|
$_SESSION['auth_date'] = $today;
|
|
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
|
$_SESSION['last_activity'] = $now;
|
|
return $user;
|
|
}
|
|
// Gebruiker bestaat niet meer
|
|
$_SESSION = [];
|
|
clear_day_session_cookie();
|
|
return null;
|
|
}
|
|
|
|
// 3. Fallback dagsessie-token: herstelt sessie na browserherstart of server idle timeout (uitsluitend vandaag!)
|
|
$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, 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) {
|
|
// 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['session_expires_at'] = strtotime('today 23:59:59');
|
|
$_SESSION['last_activity'] = $now;
|
|
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']);
|
|
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 {
|
|
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;
|
|
}
|
|
}
|
|
|
|
|