1329 lines
52 KiB
PHP
1329 lines
52 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';
|
|
require_once __DIR__ . '/settings.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;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* AES-256-GCM / AES-256-CBC encryptie helper voor gevoelige tokens (OAuth, API keys)
|
|
*/
|
|
function fociz_encrypt(string $plaintext): string {
|
|
if (empty($plaintext)) return '';
|
|
$key = hash('sha256', defined('APP_SECRET') ? APP_SECRET : 'fociz-maria-db-super-secret-key-998877', true);
|
|
$iv = random_bytes(16);
|
|
$encrypted = openssl_encrypt($plaintext, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
|
if ($encrypted === false) return $plaintext;
|
|
return 'ENC:' . base64_encode($iv . $encrypted);
|
|
}
|
|
|
|
/**
|
|
* AES-256-CBC decryptie helper; decodeert indien versleuteld, laat legacy plaintext ongemoeid
|
|
*/
|
|
function fociz_decrypt(string $ciphertext): string {
|
|
if (empty($ciphertext)) return '';
|
|
if (!str_starts_with($ciphertext, 'ENC:')) {
|
|
return $ciphertext; // Plaintext legacy waarde
|
|
}
|
|
$raw = base64_decode(substr($ciphertext, 4), true);
|
|
if ($raw === false || strlen($raw) < 17) {
|
|
return $ciphertext;
|
|
}
|
|
$iv = substr($raw, 0, 16);
|
|
$encrypted = substr($raw, 16);
|
|
$key = hash('sha256', defined('APP_SECRET') ? APP_SECRET : 'fociz-maria-db-super-secret-key-998877', true);
|
|
$decrypted = openssl_decrypt($encrypted, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
|
return ($decrypted !== false) ? $decrypted : $ciphertext;
|
|
}
|
|
|
|
/**
|
|
* Bepaalt het actuele client IP-adres (rekening houdend met reverse proxies)
|
|
*/
|
|
function fociz_get_client_ip(): string {
|
|
$headers = [
|
|
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
|
'HTTP_X_REAL_IP', // Nginx reverse proxy
|
|
'HTTP_X_FORWARDED_FOR', // Standard proxy chain
|
|
'REMOTE_ADDR' // Directe verbinding
|
|
];
|
|
|
|
foreach ($headers as $header) {
|
|
if (!empty($_SERVER[$header])) {
|
|
$ipList = explode(',', $_SERVER[$header]);
|
|
$ip = trim($ipList[0]);
|
|
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
|
return $ip;
|
|
}
|
|
}
|
|
}
|
|
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
|
}
|
|
|
|
/**
|
|
* SOC 2 & ISO 27001 Audit Trail logger
|
|
* Schrijft alle beveiligings- en beheeracties onweerlegbaar weg naar de audit_logs tabel
|
|
*/
|
|
function fociz_log_audit(?int $userId, string $action, string $resourceType = '', ?int $resourceId = null, $details = null, string $status = 'SUCCESS', ?string $userEmail = null): void {
|
|
try {
|
|
$db = get_db_connection();
|
|
$ip = fociz_get_client_ip();
|
|
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
|
|
|
|
// Indien email niet meegegeven, zoek op basis van userId
|
|
if (empty($userEmail) && !empty($userId)) {
|
|
$stmtU = $db->prepare("SELECT email FROM users WHERE id = ?");
|
|
$stmtU->execute([$userId]);
|
|
$userEmail = $stmtU->fetchColumn() ?: null;
|
|
}
|
|
|
|
$detailsJson = is_array($details) || is_object($details) ? json_encode($details, JSON_UNESCAPED_UNICODE) : (is_string($details) ? $details : null);
|
|
|
|
$statusNormalized = strtoupper($status);
|
|
if ($statusNormalized === 'FAILURE') {
|
|
$statusNormalized = 'FAILED';
|
|
}
|
|
if (!in_array($statusNormalized, ['SUCCESS', 'FAILED', 'WARNING', 'PENDING'])) {
|
|
$statusNormalized = 'SUCCESS';
|
|
}
|
|
|
|
$createdAt = date('Y-m-d H:i:s');
|
|
$stmt = $db->prepare("
|
|
INSERT INTO audit_logs (user_id, user_email, action, resource_type, resource_id, details, ip_address, user_agent, status, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
");
|
|
$stmt->execute([
|
|
$userId ?: null,
|
|
$userEmail ?: null,
|
|
$action,
|
|
$resourceType ?: null,
|
|
$resourceId ?: null,
|
|
$detailsJson,
|
|
$ip,
|
|
$ua,
|
|
$statusNormalized,
|
|
$createdAt
|
|
]);
|
|
} catch (Throwable $e) {
|
|
// Audit log mag NOOIT de applicatiewerking breken
|
|
error_log("fociz_log_audit failure: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* NIST SP 800-63B / ISO 27001 Wachtwoordvalidator
|
|
* Vereist:
|
|
* - Minimaal 12 tekens
|
|
* - Minstens 1 hoofdletter (A-Z)
|
|
* - Minstens 1 kleine letter (a-z)
|
|
* - Minstens 1 cijfer (0-9)
|
|
* - Minstens 1 speciaal teken (!@#$%^&*()_+-=[]{}|;:,.<>?)
|
|
* Geeft null terug indien geldig, of een duidelijke foutmelding in het Nederlands
|
|
*/
|
|
function fociz_validate_password(string $password): ?string {
|
|
if (strlen($password) < 12) {
|
|
return "Het wachtwoord moet minimaal 12 tekens lang zijn conform het beveiligingsbeleid.";
|
|
}
|
|
if (!preg_match('/[A-Z]/', $password)) {
|
|
return "Het wachtwoord moet minimaal één hoofdletter (A-Z) bevatten.";
|
|
}
|
|
if (!preg_match('/[a-z]/', $password)) {
|
|
return "Het wachtwoord moet minimaal één kleine letter (a-z) bevatten.";
|
|
}
|
|
if (!preg_match('/[0-9]/', $password)) {
|
|
return "Het wachtwoord moet minimaal één cijfer (0-9) bevatten.";
|
|
}
|
|
if (!preg_match('/[^A-Za-z0-9]/', $password)) {
|
|
return "Het wachtwoord moet minimaal één speciaal teken of leesteken bevatten.";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Genereert een sessie fingerprint op basis van IP en browser/platform
|
|
*/
|
|
function fociz_get_client_fingerprint(): string {
|
|
$ip = fociz_get_client_ip();
|
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
// Neem IP en User Agent mee voor strikte sessiebinding
|
|
return hash('sha256', "{$ip}|{$ua}");
|
|
}
|
|
|
|
/**
|
|
* Controleert of een IP-adres bekend en geverifieerd is voor een gebruiker
|
|
*/
|
|
function fociz_is_ip_known_for_user(int $userId, string $ip): bool {
|
|
try {
|
|
$db = get_db_connection();
|
|
// Als de gebruiker nog NOOIT een IP heeft opgeslagen, beschouwen we dit als eerste setup
|
|
$countStmt = $db->prepare("SELECT COUNT(*) FROM user_known_ips WHERE user_id = ?");
|
|
$countStmt->execute([$userId]);
|
|
$totalKnown = (int)$countStmt->fetchColumn();
|
|
if ($totalKnown === 0) {
|
|
return true; // Eerste inlog ooit
|
|
}
|
|
|
|
$stmt = $db->prepare("SELECT is_verified FROM user_known_ips WHERE user_id = ? AND ip_address = ?");
|
|
$stmt->execute([$userId, $ip]);
|
|
$row = $stmt->fetch();
|
|
return ($row && !empty($row['is_verified']));
|
|
} catch (Throwable $e) {
|
|
error_log("fociz_is_ip_known_for_user error: " . $e->getMessage());
|
|
return true; // Fallback op toegestaan bij databasefout
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Registreert of vernieuwt een IP-adres voor een gebruiker
|
|
*/
|
|
function fociz_register_user_ip(int $userId, string $ip, bool $isVerified = true): void {
|
|
try {
|
|
$db = get_db_connection();
|
|
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
|
|
$stmt = $db->prepare("
|
|
INSERT INTO user_known_ips (user_id, ip_address, user_agent, is_verified, last_seen_at)
|
|
VALUES (?, ?, ?, ?, NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
user_agent = VALUES(user_agent),
|
|
is_verified = IF(VALUES(is_verified) = 1, 1, is_verified),
|
|
last_seen_at = NOW()
|
|
");
|
|
$stmt->execute([$userId, $ip, $ua, $isVerified ? 1 : 0]);
|
|
|
|
// Werk ook last_login_ip en last_login_at bij op users tabel
|
|
$updateUser = $db->prepare("UPDATE users SET last_login_ip = ?, last_login_at = NOW() WHERE id = ?");
|
|
$updateUser->execute([$ip, $userId]);
|
|
} catch (Throwable $e) {
|
|
error_log("fociz_register_user_ip error: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Maakt een verificatietoken aan voor een nieuwe locatie/IP en stuurt een e-mail
|
|
*/
|
|
function fociz_send_new_ip_verification(int $userId, string $email, string $name, string $ip): bool {
|
|
try {
|
|
$db = get_db_connection();
|
|
$token = bin2hex(random_bytes(32));
|
|
$expiresAt = date('Y-m-d H:i:s', time() + 1800); // 30 minuten geldig
|
|
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
|
|
|
|
$stmt = $db->prepare("
|
|
INSERT INTO user_known_ips (user_id, ip_address, user_agent, is_verified, verify_token, verify_token_expires_at)
|
|
VALUES (?, ?, ?, 0, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
user_agent = VALUES(user_agent),
|
|
verify_token = VALUES(verify_token),
|
|
verify_token_expires_at = VALUES(verify_token_expires_at)
|
|
");
|
|
$stmt->execute([$userId, $ip, $ua, $token, $expiresAt]);
|
|
|
|
// Verificatie e-mail opstellen en verzenden
|
|
require_once __DIR__ . '/mailer.php';
|
|
$appUrl = defined('APP_URL') ? APP_URL : 'https://www.fociz.nl';
|
|
$verifyUrl = "{$appUrl}/auth/verify-ip.php?token={$token}";
|
|
|
|
$cleanName = !empty($name) ? htmlspecialchars($name) : 'Collega';
|
|
$timeStr = date('d-m-Y H:i');
|
|
|
|
$contentHtml = "
|
|
<p style='margin: 0 0 14px 0;'>Beste <strong>{$cleanName}</strong>,</p>
|
|
<p style='margin: 0 0 14px 0;'>
|
|
Er is zojuist geprobeerd in te loggen op uw Fociz-account vanaf een <strong>onbekende locatie of nieuw IP-adres</strong>:
|
|
</p>
|
|
<div style='background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.25); border-radius: 12px; padding: 14px 18px; margin: 18px 0;'>
|
|
<table role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%'>
|
|
<tr>
|
|
<td style='font-size: 13px; color: #94a3b8;'>Nieuw IP-adres:</td>
|
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #f87171; font-family: monospace;'>{$ip}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style='font-size: 13px; color: #94a3b8; padding-top: 6px;'>Tijdstip:</td>
|
|
<td align='right' style='font-size: 13px; font-weight: 700; color: #e2e8f0; padding-top: 6px;'>{$timeStr}</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
<p style='margin: 0 0 14px 0;'>
|
|
Bent u dit zelf? Klik dan op onderstaande knop om deze locatie te autoriseren. Hierna kunt u direct veilig inloggen.
|
|
</p>
|
|
";
|
|
|
|
$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) {
|
|
$isSecure = is_https_request();
|
|
|
|
// OWASP ASVS v4.0 §3.4 & SOC 2 CC6.6 Cryptografische Hardening
|
|
ini_set('session.sid_length', '48');
|
|
ini_set('session.sid_bits_per_character', '6');
|
|
ini_set('session.use_strict_mode', '1');
|
|
ini_set('session.use_only_cookies', '1');
|
|
ini_set('session.use_trans_sid', '0');
|
|
ini_set('session.gc_maxlifetime', '86400');
|
|
ini_set('session.cookie_httponly', '1');
|
|
|
|
// Sessienaam instellen (veilig en uniform)
|
|
session_name('fociz_session');
|
|
|
|
session_set_cookie_params([
|
|
'lifetime' => 0, // Browser sessiecookie: vervalt direct bij afsluiten browser conform ISO 27001 / NIST SP 800-63B
|
|
'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()");
|
|
}
|
|
|
|
$resActivity = $db->query("SHOW COLUMNS FROM `users` LIKE 'last_activity_at'");
|
|
if (!$resActivity || $resActivity->rowCount() === 0) {
|
|
$db->exec("ALTER TABLE `users` ADD COLUMN `last_activity_at` DATETIME NULL AFTER `remember_token_expires_at`");
|
|
}
|
|
} catch (Throwable $e) {
|
|
// Stil negeren indien al aanwezig of tijdens migraties
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SOC 2 & ISO 27001 Actieve Sessietabel
|
|
* Garandeert dat de tabel user_sessions aanwezig is voor multi-session tracking en remote revocation
|
|
*/
|
|
function ensure_sessions_table(PDO $db): void {
|
|
static $sessionsTableChecked = false;
|
|
if ($sessionsTableChecked) return;
|
|
$sessionsTableChecked = true;
|
|
|
|
try {
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS `user_sessions` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`user_id` INT NOT NULL,
|
|
`session_id_hash` VARCHAR(64) NOT NULL UNIQUE,
|
|
`ip_address` VARCHAR(45) NOT NULL,
|
|
`user_agent` VARCHAR(255) NULL,
|
|
`device_name` VARCHAR(100) NULL,
|
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
|
`last_activity_at` DATETIME NOT NULL,
|
|
`expires_at` DATETIME NOT NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX (`user_id`),
|
|
INDEX (`session_id_hash`),
|
|
INDEX (`expires_at`),
|
|
INDEX (`is_active`),
|
|
CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
");
|
|
} catch (Throwable $e) {
|
|
error_log("ensure_sessions_table notice: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zet User-Agent om naar een herkenbare apparaatnaam (SOC 2 / ISO 27001 UI)
|
|
*/
|
|
function fociz_get_device_name(?string $userAgent): string {
|
|
if (empty($userAgent)) return 'Onbekend apparaat';
|
|
$ua = $userAgent;
|
|
|
|
$platform = 'Onbekend OS';
|
|
if (stripos($ua, 'Windows NT 10.0') !== false) $platform = 'Windows 10/11';
|
|
elseif (stripos($ua, 'Windows') !== false) $platform = 'Windows';
|
|
elseif (stripos($ua, 'Macintosh') !== false || stripos($ua, 'Mac OS X') !== false) $platform = 'macOS';
|
|
elseif (stripos($ua, 'iPhone') !== false) $platform = 'iPhone';
|
|
elseif (stripos($ua, 'iPad') !== false) $platform = 'iPad';
|
|
elseif (stripos($ua, 'Android') !== false) $platform = 'Android';
|
|
elseif (stripos($ua, 'Linux') !== false) $platform = 'Linux';
|
|
|
|
$browser = 'Browser';
|
|
if (stripos($ua, 'Edg/') !== false) $browser = 'Edge';
|
|
elseif (stripos($ua, 'Chrome/') !== false && stripos($ua, 'Chromium') === false) $browser = 'Chrome';
|
|
elseif (stripos($ua, 'Safari/') !== false && stripos($ua, 'Chrome') === false) $browser = 'Safari';
|
|
elseif (stripos($ua, 'Firefox/') !== false) $browser = 'Firefox';
|
|
elseif (stripos($ua, 'Opera') !== false || stripos($ua, 'OPR/') !== false) $browser = 'Opera';
|
|
|
|
return "{$browser} op {$platform}";
|
|
}
|
|
|
|
/**
|
|
* Registreert of vernieuwt een actieve sessie in user_sessions (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
|
*/
|
|
function fociz_register_active_session(int $userId, string $sessionId, int $maxIdleSeconds): void {
|
|
try {
|
|
if (empty($sessionId) || $userId <= 0) return;
|
|
$db = get_db_connection();
|
|
ensure_sessions_table($db);
|
|
|
|
$hash = hash('sha256', $sessionId);
|
|
$ip = fociz_get_client_ip();
|
|
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
|
|
$deviceName = fociz_get_device_name($ua);
|
|
$expiresAt = date('Y-m-d H:i:s', time() + $maxIdleSeconds);
|
|
|
|
$stmt = $db->prepare("
|
|
INSERT INTO user_sessions (user_id, session_id_hash, ip_address, user_agent, device_name, is_active, last_activity_at, expires_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, 1, NOW(), ?, NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
ip_address = VALUES(ip_address),
|
|
user_agent = VALUES(user_agent),
|
|
device_name = VALUES(device_name),
|
|
is_active = 1,
|
|
last_activity_at = NOW(),
|
|
expires_at = VALUES(expires_at)
|
|
");
|
|
$stmt->execute([$userId, $hash, $ip, $ua, $deviceName, $expiresAt]);
|
|
|
|
// Verwijder oude verlopen sessies (> 7 dagen geleden verlopen)
|
|
$db->exec("DELETE FROM user_sessions WHERE expires_at < NOW() - INTERVAL 7 DAY");
|
|
} catch (Throwable $e) {
|
|
error_log("fociz_register_active_session error: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Werkt de activiteitstijd en expiratiedatum bij van een actieve sessie
|
|
*/
|
|
function fociz_touch_session(string $sessionId, int $maxIdleSeconds): void {
|
|
try {
|
|
if (empty($sessionId)) return;
|
|
$db = get_db_connection();
|
|
$hash = hash('sha256', $sessionId);
|
|
$expiresAt = date('Y-m-d H:i:s', time() + $maxIdleSeconds);
|
|
$stmt = $db->prepare("UPDATE user_sessions SET last_activity_at = NOW(), expires_at = ? WHERE session_id_hash = ? AND is_active = 1");
|
|
$stmt->execute([$expiresAt, $hash]);
|
|
} catch (Throwable $e) {
|
|
error_log("fociz_touch_session error: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Roteert een sessie-ID in de database (OWASP ASVS v4.0 §3.3)
|
|
*/
|
|
function fociz_rotate_session(string $oldSessionId, string $newSessionId): void {
|
|
try {
|
|
if (empty($oldSessionId) || empty($newSessionId) || $oldSessionId === $newSessionId) return;
|
|
$db = get_db_connection();
|
|
$oldHash = hash('sha256', $oldSessionId);
|
|
$newHash = hash('sha256', $newSessionId);
|
|
$stmt = $db->prepare("UPDATE user_sessions SET session_id_hash = ? WHERE session_id_hash = ?");
|
|
$stmt->execute([$newHash, $oldHash]);
|
|
} catch (Throwable $e) {
|
|
error_log("fociz_rotate_session error: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Controleert of de huidige sessie nog actief is (of ingetrokken op afstand)
|
|
*/
|
|
function fociz_is_session_active(string $sessionId, int $userId): bool {
|
|
try {
|
|
if (empty($sessionId) || $userId <= 0) return false;
|
|
$db = get_db_connection();
|
|
ensure_sessions_table($db);
|
|
|
|
$hash = hash('sha256', $sessionId);
|
|
$stmt = $db->prepare("SELECT is_active, expires_at FROM user_sessions WHERE session_id_hash = ? AND user_id = ?");
|
|
$stmt->execute([$hash, $userId]);
|
|
$row = $stmt->fetch();
|
|
if (!$row) {
|
|
// Als sessie nog niet geregistreerd staat, registreer direct om tracking consistent te houden
|
|
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
|
fociz_register_active_session($userId, $sessionId, $maxIdleSeconds);
|
|
return true;
|
|
}
|
|
return (!empty($row['is_active']));
|
|
} catch (Throwable $e) {
|
|
return true; // Fallback op toegestaan bij databaseverstoring
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Beëindigt een sessie in de database (bij uitloggen of time-out)
|
|
*/
|
|
function fociz_terminate_session(string $sessionId): void {
|
|
try {
|
|
if (empty($sessionId)) return;
|
|
$db = get_db_connection();
|
|
$hash = hash('sha256', $sessionId);
|
|
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE session_id_hash = ?");
|
|
$stmt->execute([$hash]);
|
|
} catch (Throwable $e) {
|
|
error_log("fociz_terminate_session error: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trekt alle overige sessies van een gebruiker in (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
|
*/
|
|
function fociz_revoke_other_sessions(int $userId, string $currentSessionId): int {
|
|
try {
|
|
$db = get_db_connection();
|
|
ensure_sessions_table($db);
|
|
$currentHash = hash('sha256', $currentSessionId);
|
|
|
|
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE user_id = ? AND session_id_hash != ? AND is_active = 1");
|
|
$stmt->execute([$userId, $currentHash]);
|
|
$revokedCount = $stmt->rowCount();
|
|
|
|
// Invalideer ook het dagsessietoken in de database zodat herstel op andere browsers direct stopt
|
|
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
|
$stmtClear->execute([$userId]);
|
|
|
|
fociz_log_audit($userId, 'REVOKE_OTHER_SESSIONS', 'user_sessions', $userId, ['revoked_count' => $revokedCount], 'SUCCESS');
|
|
return $revokedCount;
|
|
} catch (Throwable $e) {
|
|
error_log("fociz_revoke_other_sessions error: " . $e->getMessage());
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trekt een specifieke sessie in op basis van ID
|
|
*/
|
|
function fociz_revoke_session_by_id(int $userId, int $sessionDbId): bool {
|
|
try {
|
|
$db = get_db_connection();
|
|
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE id = ? AND user_id = ?");
|
|
$stmt->execute([$sessionDbId, $userId]);
|
|
fociz_log_audit($userId, 'SESSION_TERMINATED_REVOKED', 'user_sessions', $sessionDbId, ['session_db_id' => $sessionDbId], 'SUCCESS');
|
|
return true;
|
|
} catch (Throwable $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Haalt alle actieve sessies van een gebruiker op voor de profielpagina
|
|
*/
|
|
function fociz_get_user_active_sessions(int $userId, string $currentSessionId): array {
|
|
try {
|
|
$db = get_db_connection();
|
|
ensure_sessions_table($db);
|
|
$currentHash = hash('sha256', $currentSessionId);
|
|
|
|
$stmt = $db->prepare("
|
|
SELECT id, ip_address, user_agent, device_name, last_activity_at, created_at, (session_id_hash = ?) AS is_current
|
|
FROM user_sessions
|
|
WHERE user_id = ? AND is_active = 1
|
|
ORDER BY is_current DESC, last_activity_at DESC
|
|
");
|
|
$stmt->execute([$currentHash, $userId]);
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
} catch (Throwable $e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Valideert en normaliseert een avatar URL.
|
|
* Controleert of geüploade bestanden nog fysiek aanwezig zijn op de server (bijv. na container herstart).
|
|
* Indien een bestand ontbreekt, wordt de database automatisch hersteld (self-healing) en null geretourneerd.
|
|
*/
|
|
function fociz_get_valid_avatar_url(?string $avatarUrl, ?int $userId = null): ?string {
|
|
if (empty($avatarUrl)) {
|
|
return null;
|
|
}
|
|
|
|
// 1. Data-URI (base64) of meegeleverde repository preset SVG: altijd persistent en geldig
|
|
if (str_starts_with($avatarUrl, 'data:image/') || str_starts_with($avatarUrl, '/assets/avatars/')) {
|
|
return $avatarUrl;
|
|
}
|
|
|
|
// 2. Geüpload bestand (/assets/uploads/...): controleer fysieke aanwezigheid op server
|
|
if (str_starts_with($avatarUrl, '/assets/uploads/')) {
|
|
$localPath = dirname(__DIR__) . '/' . ltrim($avatarUrl, '/');
|
|
if (file_exists($localPath)) {
|
|
return $avatarUrl;
|
|
}
|
|
|
|
// Fysiek bestand ontbreekt (bijv. door container herstart of git deploy)
|
|
if ($userId) {
|
|
try {
|
|
$db = get_db_connection();
|
|
$stmt = $db->prepare("UPDATE users SET avatar_url = NULL WHERE id = ? AND avatar_url = ?");
|
|
$stmt->execute([$userId, $avatarUrl]);
|
|
if (isset($_SESSION['user_avatar_url']) && $_SESSION['user_avatar_url'] === $avatarUrl) {
|
|
unset($_SESSION['user_avatar_url']);
|
|
}
|
|
} catch (Throwable $e) {
|
|
error_log("Self-healing avatar cleanup notice: " . $e->getMessage());
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return $avatarUrl;
|
|
}
|
|
|
|
/**
|
|
* Verwerkt een geüploade avatar-afbeelding.
|
|
* - Knipt vierkant uit en comprimeert naar max. 256x256 via GD
|
|
* - Converteert naar Base64 Data URI voor 100% gegarandeerde persistentie in MariaDB
|
|
* - Slaat tevens optioneel een backup op in assets/uploads/avatars/
|
|
*/
|
|
function fociz_process_avatar_upload(array $file, int $userId): array {
|
|
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
$mime = finfo_file($finfo, $file['tmp_name']);
|
|
finfo_close($finfo);
|
|
|
|
if (!in_array($mime, $allowedTypes)) {
|
|
return ['success' => false, 'error' => 'Alleen JPG, PNG, WebP of GIF afbeeldingen zijn toegestaan.'];
|
|
}
|
|
|
|
$ext = match($mime) {
|
|
'image/jpeg' => 'jpg',
|
|
'image/png' => 'png',
|
|
'image/webp' => 'webp',
|
|
'image/gif' => 'gif',
|
|
default => 'jpg'
|
|
};
|
|
|
|
$rawContent = file_get_contents($file['tmp_name']);
|
|
if ($rawContent === false || strlen($rawContent) === 0) {
|
|
return ['success' => false, 'error' => 'Kon geüploade afbeelding niet inlezen.'];
|
|
}
|
|
|
|
$finalMime = $mime;
|
|
$finalData = $rawContent;
|
|
|
|
// Verklein en comprimeer naar max 256x256 vierkant via GD
|
|
if (extension_loaded('gd') && function_exists('imagecreatefromstring')) {
|
|
try {
|
|
$srcImg = @imagecreatefromstring($rawContent);
|
|
if ($srcImg !== false) {
|
|
$w = imagesx($srcImg);
|
|
$h = imagesy($srcImg);
|
|
$maxDim = 256;
|
|
|
|
$minDim = min($w, $h);
|
|
$cropX = (int)(($w - $minDim) / 2);
|
|
$cropY = (int)(($h - $minDim) / 2);
|
|
|
|
$targetSize = min($maxDim, $minDim);
|
|
$dstImg = imagecreatetruecolor($targetSize, $targetSize);
|
|
|
|
imagealphablending($dstImg, false);
|
|
imagesavealpha($dstImg, true);
|
|
|
|
imagecopyresampled($dstImg, $srcImg, 0, 0, $cropX, $cropY, $targetSize, $targetSize, $minDim, $minDim);
|
|
|
|
ob_start();
|
|
if ($mime === 'image/png') {
|
|
imagepng($dstImg, null, 8);
|
|
$finalMime = 'image/png';
|
|
} elseif (function_exists('imagewebp') && ($mime === 'image/webp' || $mime === 'image/jpeg')) {
|
|
imagewebp($dstImg, null, 85);
|
|
$finalMime = 'image/webp';
|
|
} else {
|
|
imagejpeg($dstImg, null, 85);
|
|
$finalMime = 'image/jpeg';
|
|
}
|
|
$compressed = ob_get_clean();
|
|
if (!empty($compressed)) {
|
|
$finalData = $compressed;
|
|
}
|
|
imagedestroy($dstImg);
|
|
imagedestroy($srcImg);
|
|
}
|
|
} catch (Throwable $e) {
|
|
error_log("fociz avatar resize notice: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// Bouw data URI voor permanente opslag in MariaDB
|
|
$dataUri = 'data:' . $finalMime . ';base64,' . base64_encode($finalData);
|
|
|
|
// Sla bestand ook lokaal op als backup / snelle cache
|
|
$uploadDir = dirname(__DIR__) . '/assets/uploads/avatars';
|
|
if (!is_dir($uploadDir)) {
|
|
@mkdir($uploadDir, 0755, true);
|
|
}
|
|
$filename = "avatar_{$userId}_" . time() . "_" . bin2hex(random_bytes(4)) . ".{$ext}";
|
|
@file_put_contents($uploadDir . '/' . $filename, $finalData);
|
|
|
|
return [
|
|
'success' => true,
|
|
'data_uri' => $dataUri,
|
|
'web_path' => "/assets/uploads/avatars/{$filename}"
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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 = ?, last_activity_at = NOW() 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();
|
|
|
|
// Bepaal of dit een geautomatiseerde achtergrond poll is (zoals notifications, live sync of outlook polling)
|
|
$isBackgroundPoll = !empty($_SERVER['HTTP_X_BACKGROUND_POLL']) ||
|
|
(isset($_SERVER['REQUEST_URI']) && (
|
|
strpos($_SERVER['REQUEST_URI'], '/api/live_sync.php') !== false ||
|
|
(strpos($_SERVER['REQUEST_URI'], '/api/notifications.php') !== false && ($_GET['action'] ?? '') === 'get_events') ||
|
|
(strpos($_SERVER['REQUEST_URI'], '/api/planner.php') !== false && ($_GET['action'] ?? '') === 'check_outlook_sync')
|
|
));
|
|
|
|
// 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);
|
|
|
|
$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'])) {
|
|
// Controleer of sessie op afstand is ingetrokken (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
|
|
if (!fociz_is_session_active(session_id(), (int)$_SESSION['user_id'])) {
|
|
fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_TERMINATED_REVOKED', 'user_sessions', (int)$_SESSION['user_id'], ['reason' => 'remote_revocation'], 'WARNING');
|
|
$_SESSION = ['session_was_expired' => true, 'session_revoked' => true];
|
|
clear_day_session_cookie();
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
@session_regenerate_id(true);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Dynamische inactiviteitstimeout conform NIST SP 800-63B / ISO 27001 / OWASP ASVS v4.0
|
|
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
|
if (!empty($_SESSION['last_activity']) && ($now - (int)$_SESSION['last_activity']) > $maxIdleSeconds) {
|
|
fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_IDLE_TIMEOUT', 'users', (int)$_SESSION['user_id'], ['idle_seconds' => $now - (int)$_SESSION['last_activity'], 'max_idle' => $maxIdleSeconds], 'WARNING');
|
|
fociz_terminate_session(session_id());
|
|
// Invalideer dagsessie token in database zodat herstel via cookie uitgesloten is
|
|
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
|
$stmtClear->execute([$_SESSION['user_id']]);
|
|
$_SESSION = ['session_was_expired' => true, 'idle_timeout' => true];
|
|
clear_day_session_cookie();
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
@session_regenerate_id(true);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Absolute sessielimiet (NIST SP 800-63B): max 10 uur aaneengesloten sessie per dag
|
|
$maxSessionDuration = 36000;
|
|
if (!empty($_SESSION['login_time']) && ($now - (int)$_SESSION['login_time']) > $maxSessionDuration) {
|
|
fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_ABSOLUTE_TIMEOUT', 'users', (int)$_SESSION['user_id'], ['duration' => $now - (int)$_SESSION['login_time']], 'WARNING');
|
|
fociz_terminate_session(session_id());
|
|
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
|
$stmtClear->execute([$_SESSION['user_id']]);
|
|
$_SESSION = ['session_was_expired' => true, 'absolute_timeout' => true];
|
|
clear_day_session_cookie();
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
@session_regenerate_id(true);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 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) {
|
|
// Periodieke sessie-ID rotatie (OWASP ASVS v4.0 §3.3): elke 30 minuten ter voorkoming van session hijacking
|
|
if (empty($_SESSION['last_rotated'])) {
|
|
$_SESSION['last_rotated'] = $now;
|
|
} elseif (($now - (int)$_SESSION['last_rotated']) > 1800) {
|
|
$oldSid = session_id();
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
@session_regenerate_id(true);
|
|
fociz_rotate_session($oldSid, session_id());
|
|
}
|
|
$_SESSION['last_rotated'] = $now;
|
|
}
|
|
|
|
if (empty($_SESSION['login_time'])) {
|
|
$_SESSION['login_time'] = $now;
|
|
}
|
|
|
|
$_SESSION['auth_date'] = $today;
|
|
$_SESSION['auth_fingerprint'] = $clientFingerprint;
|
|
$_SESSION['session_expires_at'] = strtotime('today 23:59:59');
|
|
|
|
// Achtergrondpolls verversen last_activity NIET; alleen daadwerkelijke gebruikersinteractie houdt de sessie actief
|
|
if (!$isBackgroundPoll) {
|
|
$_SESSION['last_activity'] = $now;
|
|
if (empty($_SESSION['last_db_activity_update']) || ($now - (int)$_SESSION['last_db_activity_update']) > 60) {
|
|
$_SESSION['last_db_activity_update'] = $now;
|
|
try {
|
|
$stmtAct = $db->prepare("UPDATE users SET last_activity_at = NOW() WHERE id = ?");
|
|
$stmtAct->execute([$_SESSION['user_id']]);
|
|
fociz_touch_session(session_id(), $maxIdleSeconds);
|
|
} catch (Throwable $e) {}
|
|
}
|
|
}
|
|
|
|
$user['avatar_url'] = fociz_get_valid_avatar_url($user['avatar_url'] ?? null, (int)$user['id']);
|
|
$_SESSION['user_avatar_url'] = $user['avatar_url'];
|
|
return $user;
|
|
}
|
|
// Gebruiker bestaat niet meer
|
|
$_SESSION = [];
|
|
clear_day_session_cookie();
|
|
return null;
|
|
}
|
|
|
|
// 3. Fallback dagsessie-token: herstelt sessie na browserherstart (uitsluitend vandaag én mits niet inactief!)
|
|
$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, last_activity_at, last_login_at, 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) {
|
|
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
|
|
// Controleer inactiviteit: als gebruiker te lang inactief is geweest, herstel weigeren en opnieuw inloggen
|
|
$lastActTime = !empty($user['last_activity_at']) ? strtotime($user['last_activity_at']) : (!empty($user['last_login_at']) ? strtotime($user['last_login_at']) : 0);
|
|
if ($lastActTime > 0 && ($now - $lastActTime) > $maxIdleSeconds) {
|
|
fociz_log_audit((int)$user['id'], 'SESSION_IDLE_TIMEOUT_COOKIE', 'users', (int)$user['id'], ['idle_seconds' => $now - $lastActTime, 'max_idle' => $maxIdleSeconds], 'WARNING');
|
|
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
|
|
$stmtClear->execute([$user['id']]);
|
|
$_SESSION = ['session_was_expired' => true, 'idle_timeout' => true];
|
|
clear_day_session_cookie();
|
|
return null;
|
|
}
|
|
|
|
$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'];
|
|
$_SESSION['user_email'] = $user['email'];
|
|
$_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;
|
|
$_SESSION['login_time'] = $now;
|
|
$_SESSION['last_rotated'] = $now;
|
|
$_SESSION['last_db_activity_update'] = $now;
|
|
try {
|
|
$stmtAct = $db->prepare("UPDATE users SET last_activity_at = NOW() WHERE id = ?");
|
|
$stmtAct->execute([$user['id']]);
|
|
} catch (Throwable $e) {}
|
|
|
|
fociz_register_active_session((int)$user['id'], session_id(), $maxIdleSeconds);
|
|
|
|
$user['avatar_url'] = fociz_get_valid_avatar_url($user['avatar_url'] ?? null, (int)$user['id']);
|
|
$_SESSION['user_avatar_url'] = $user['avatar_url'];
|
|
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']);
|
|
$ipChanged = !empty($_SESSION['ip_changed']);
|
|
$wasRevoked = !empty($_SESSION['session_revoked']);
|
|
unset($_SESSION['session_was_expired'], $_SESSION['ip_changed'], $_SESSION['session_revoked']);
|
|
if ($ipChanged) {
|
|
header('Location: /auth/login.php?ip_changed=1');
|
|
} elseif ($wasRevoked) {
|
|
header('Location: /auth/login.php?revoked=1');
|
|
} elseif ($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);
|
|
}
|
|
|
|
/**
|
|
* Rate limiting helpers via app_cache
|
|
*/
|
|
function fociz_check_rate_limit(string $actionKey, int $maxAttempts = 5, int $decaySeconds = 900): array {
|
|
require_once __DIR__ . '/cache.php';
|
|
$cacheKey = 'rl_' . hash('sha256', $actionKey);
|
|
$data = fociz_cache_get($cacheKey);
|
|
|
|
$attempts = 0;
|
|
$expiresAt = time() + $decaySeconds;
|
|
if (is_array($data)) {
|
|
$attempts = (int)($data['attempts'] ?? 0);
|
|
$expiresAt = (int)($data['expires_at'] ?? $expiresAt);
|
|
}
|
|
|
|
$allowed = ($attempts < $maxAttempts);
|
|
$remaining = max(0, $maxAttempts - $attempts);
|
|
$retryAfter = max(0, $expiresAt - time());
|
|
|
|
return [
|
|
'allowed' => $allowed,
|
|
'remaining' => $remaining,
|
|
'retry_after' => $retryAfter,
|
|
'attempts' => $attempts
|
|
];
|
|
}
|
|
|
|
function fociz_hit_rate_limit(string $actionKey, int $decaySeconds = 900): void {
|
|
require_once __DIR__ . '/cache.php';
|
|
$cacheKey = 'rl_' . hash('sha256', $actionKey);
|
|
$data = fociz_cache_get($cacheKey);
|
|
|
|
$attempts = 1;
|
|
$expiresAt = time() + $decaySeconds;
|
|
if (is_array($data)) {
|
|
$attempts = (int)($data['attempts'] ?? 0) + 1;
|
|
$expiresAt = (int)($data['expires_at'] ?? $expiresAt);
|
|
}
|
|
|
|
$ttl = max(1, $expiresAt - time());
|
|
fociz_cache_set($cacheKey, ['attempts' => $attempts, 'expires_at' => $expiresAt], $ttl);
|
|
}
|
|
|
|
function fociz_clear_rate_limit(string $actionKey): void {
|
|
require_once __DIR__ . '/cache.php';
|
|
$cacheKey = 'rl_' . hash('sha256', $actionKey);
|
|
fociz_cache_delete($cacheKey);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
|