diff --git a/api/integrations.php b/api/integrations.php index 7c8656c..edefbfa 100644 --- a/api/integrations.php +++ b/api/integrations.php @@ -28,7 +28,7 @@ try { $stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'JIRA'"); $stmt->execute([$user['id']]); $saved = json_decode($stmt->fetchColumn() ?: '{}', true); - $apiToken = $saved['jira_api_token'] ?? ''; + $apiToken = fociz_decrypt($saved['jira_api_token'] ?? ''); if (empty($domain)) $domain = $saved['jira_domain'] ?? ''; if (empty($email)) $email = $saved['jira_email'] ?? ''; } @@ -40,7 +40,7 @@ try { $settings = json_encode([ 'jira_domain' => jira_normalize_domain($domain), 'jira_email' => $email, - 'jira_api_token' => $apiToken, + 'jira_api_token' => fociz_encrypt($apiToken), 'account_name' => $jiraUser['displayName'] ?? '', 'account_id' => $jiraUser['accountId'] ?? '', ]); diff --git a/auth/callback_microsoft.php b/auth/callback_microsoft.php index d86525e..c64ef39 100644 --- a/auth/callback_microsoft.php +++ b/auth/callback_microsoft.php @@ -137,7 +137,12 @@ $saveStmt = $db->prepare(" settings = VALUES(settings), last_sync_at = NOW() "); -$saveStmt->execute([$user['id'], $accessToken, $refreshToken, json_encode($newSettings)]); +$saveStmt->execute([ + $user['id'], + fociz_encrypt($accessToken), + fociz_encrypt($refreshToken), + json_encode($newSettings) +]); // 8. Succesvol terugkeren naar de gewenste pagina of het Persoonlijk Profiel $returnTo = $_SESSION['ms_oauth_return_to'] ?? '/profile.php'; diff --git a/auth/login.php b/auth/login.php index cd576b5..968de55 100644 --- a/auth/login.php +++ b/auth/login.php @@ -22,13 +22,19 @@ if ($currentUser) { $error = null; $infoMessage = null; if (isset($_GET['expired'])) { - $infoMessage = "Uw dagsessie is om 23:59 beëindigd. Log opnieuw in om een nieuwe werkdag te starten."; + $infoMessage = "Uw dagsessie is beëindigd. Log opnieuw in om verder te gaan."; +} +if (isset($_GET['ip_changed'])) { + $infoMessage = "U bent ingelogd vanaf een andere netwerklocatie. Uit veiligheidsoverwegingen dient u opnieuw in te loggen met 2FA."; +} +if (isset($_GET['ip_verified'])) { + $infoMessage = "Nieuwe inloglocatie succesvol geverifieerd! U kunt nu veilig inloggen."; } $step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1; // Terug naar stap 1 if (isset($_GET['action']) && $_GET['action'] === 'restart') { - unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email']); + unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name'], $_SESSION['pending_new_ip']); header('Location: /auth/login.php'); exit; } @@ -50,11 +56,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $user = $stmt->fetch(); if ($user && password_verify($password, $user['password_hash'])) { - // Succesvol stap 1: zet tijdelijke pending sessie voor 2FA - $_SESSION['pending_2fa_user_id'] = $user['id']; - $_SESSION['pending_2fa_user_email'] = $user['email']; - $_SESSION['pending_2fa_user_name'] = $user['name']; - $step = 2; + // Controleer of de inloglocatie/IP bekend is + $clientIp = fociz_get_client_ip(); + $isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp); + + if (!$isIpKnown) { + // Stuur verificatie-mail en informeer gebruiker + fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp); + $error = "Nieuwe inloglocatie gedetecteerd ({$clientIp}). Er is een verificatie-e-mail gestuurd naar {$user['email']}. Klik op de link in de e-mail om deze locatie toe te staan."; + } else { + // Succesvol stap 1: zet tijdelijke pending sessie voor 2FA + $_SESSION['pending_2fa_user_id'] = $user['id']; + $_SESSION['pending_2fa_user_email'] = $user['email']; + $_SESSION['pending_2fa_user_name'] = $user['name']; + $step = 2; + } } else { $error = "Onjuist e-mailadres of wachtwoord."; } @@ -105,12 +121,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($isValid) { // Volledige login succesvol (geldig tot 23:59:59 van vandaag) + $clientIp = fociz_get_client_ip(); + fociz_register_user_ip((int)$user['id'], $clientIp, true); + $_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'] = date('Y-m-d'); + $_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint(); $_SESSION['session_expires_at'] = strtotime('today 23:59:59'); $_SESSION['last_activity'] = time(); diff --git a/auth/verify-ip.php b/auth/verify-ip.php new file mode 100644 index 0000000..0892c25 --- /dev/null +++ b/auth/verify-ip.php @@ -0,0 +1,135 @@ +prepare(" + SELECT kip.*, u.email, u.name + FROM user_known_ips kip + JOIN users u ON kip.user_id = u.id + WHERE kip.verify_token = ? + "); + $stmt->execute([$token]); + $record = $stmt->fetch(); + + if (!$record) { + $error = "Deze verificatielink is niet gevonden of al gebruikt."; + } elseif (!empty($record['verify_token_expires_at']) && strtotime($record['verify_token_expires_at']) < time()) { + $error = "Deze verificatielink is verlopen (geldigheidsduur was 30 minuten). Probeer opnieuw in te loggen om een nieuwe link te ontvangen."; + } else { + // Verifieer het IP-adres + $updateStmt = $db->prepare(" + UPDATE user_known_ips + SET is_verified = 1, verify_token = NULL, verify_token_expires_at = NULL, last_seen_at = NOW() + WHERE id = ? + "); + $updateStmt->execute([$record['id']]); + + $success = true; + $userEmail = $record['email']; + $ipAddress = $record['ip_address']; + } + } catch (Throwable $e) { + error_log("verify-ip.php error: " . $e->getMessage()); + $error = "Er is een databasefout opgetreden bij het verifiëren."; + } +} + +$pageTitle = "Fociz — Locatie Verificatie"; +?> + + +
+ + +Beveiligde inlogtoegang voor Fociz
++ Het IP-adres = htmlspecialchars($ipAddress) ?> is nu geautoriseerd voor uw account (= htmlspecialchars($userEmail) ?>). +
++ = htmlspecialchars($error ?? 'Onbekende fout.') ?> +
+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 @@ -209,13 +391,26 @@ function get_logged_in_user(): ?array { $db = get_db_connection(); ensure_remember_token_columns($db); - // 2. Controleer actieve PHP sessie (mits behorend bij vandaag) + $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'])) { + // 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) { $_SESSION['auth_date'] = $today; + $_SESSION['auth_fingerprint'] = $clientFingerprint; $_SESSION['session_expires_at'] = strtotime('today 23:59:59'); $_SESSION['last_activity'] = $now; return $user; @@ -233,6 +428,13 @@ function get_logged_in_user(): ?array { $stmt->execute([$dayToken, $today]); $user = $stmt->fetch(); if ($user) { + $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']; @@ -240,6 +442,7 @@ function get_logged_in_user(): ?array { $_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; return $user; @@ -287,8 +490,11 @@ function require_auth(bool $json = false): array { exit; } $wasExpired = !empty($_SESSION['session_was_expired']); - unset($_SESSION['session_was_expired']); - if ($wasExpired) { + $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'); diff --git a/includes/cache.php b/includes/cache.php index 003a620..b458f95 100644 --- a/includes/cache.php +++ b/includes/cache.php @@ -137,3 +137,24 @@ function fociz_cache_delete_pattern(string $prefix): bool { return false; } } + +/** + * Verwijdert alle verlopen items actief uit de cache tabel (AVG/GDPR opschoning) + */ +function fociz_cache_cleanup_expired(?PDO $db = null): int { + try { + $pdo = $db ?: get_db_connection(); + ensure_app_cache_table($pdo); + $stmt = $pdo->prepare("DELETE FROM app_cache WHERE expires_at < ?"); + $stmt->execute([time()]); + return $stmt->rowCount(); + } catch (Throwable $e) { + error_log("fociz_cache_cleanup_expired error: " . $e->getMessage()); + return 0; + } +} + +// Probabilistische automatische GC (5% kans bij elke script execution) +if (mt_rand(1, 20) === 1) { + fociz_cache_cleanup_expired(); +} diff --git a/includes/integrations/jira.php b/includes/integrations/jira.php index 1739bce..a71eaba 100644 --- a/includes/integrations/jira.php +++ b/includes/integrations/jira.php @@ -866,7 +866,8 @@ function jira_get_dashboard_data(int $userId, bool $forceRefresh = false): array $settings = json_decode($integration['settings'] ?? '{}', true) ?: []; $domain = !empty($settings['jira_domain']) ? $settings['jira_domain'] : jira_get_company_domain(); $email = $settings['jira_email'] ?? fociz_env('JIRA_EMAIL', ''); - $apiToken = $settings['jira_api_token'] ?? fociz_env('JIRA_API_TOKEN', ''); + $apiTokenRaw = $settings['jira_api_token'] ?? fociz_env('JIRA_API_TOKEN', ''); + $apiToken = fociz_decrypt($apiTokenRaw); $accName = $settings['account_name'] ?? 'Atlassian Gebruiker'; if (empty($domain) || empty($email) || empty($apiToken)) { diff --git a/includes/integrations/microsoft.php b/includes/integrations/microsoft.php index 1f83a8c..656fc03 100644 --- a/includes/integrations/microsoft.php +++ b/includes/integrations/microsoft.php @@ -218,23 +218,25 @@ function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?st return null; } + $accessToken = fociz_decrypt($integration['access_token']); + $refreshToken = fociz_decrypt($integration['refresh_token'] ?? ''); + $settings = json_decode($integration['settings'] ?? '{}', true) ?: []; $expiresAt = (int)($settings['expires_at'] ?? 0); // Als token nog minstens 4 minuten geldig is én we niet forceren, direct retourneren if (!$forceRefresh && $expiresAt > (time() + 240)) { - return $integration['access_token']; + return $accessToken; } // Token is verlopen of forceRefresh gevraagd; refresh met refresh_token - $refreshToken = $integration['refresh_token'] ?? ''; $appConfig = microsoft_get_app_config(); $clientId = !empty($settings['client_id']) ? $settings['client_id'] : $appConfig['client_id']; $clientSecret = !empty($settings['client_secret']) ? $settings['client_secret'] : $appConfig['client_secret']; $tenantId = !empty($settings['tenant_id']) ? $settings['tenant_id'] : $appConfig['tenant_id']; if (empty($refreshToken) || empty($clientId) || empty($clientSecret)) { - return !$forceRefresh ? ($integration['access_token'] ?? null) : null; + return !$forceRefresh ? $accessToken : null; } $cleanTenant = (!empty($tenantId) && $tenantId !== 'common') ? $tenantId : 'planningsagenda.nl'; @@ -264,15 +266,20 @@ function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?st SET access_token = ?, refresh_token = ?, settings = ?, last_sync_at = NOW() WHERE id = ? "); - $updateStmt->execute([$newAccessToken, $newRefreshToken, json_encode($settings), $integration['id']]); + $updateStmt->execute([ + fociz_encrypt($newAccessToken), + fociz_encrypt($newRefreshToken), + json_encode($settings), + $integration['id'] + ]); return $newAccessToken; } error_log("Microsoft token refresh mislukt voor user {$userId}: " . ($response['error'] ?? json_encode($response['json']))); // Fallback: indien het huidige token nog aanwezig is, probeer dit toch te gebruiken - if (!empty($integration['access_token'])) { - return $integration['access_token']; + if (!empty($accessToken)) { + return $accessToken; } return null; }