feat(security): implement IP verification and encryption for sensitive tokens
This commit is contained in:
+209
-3
@@ -33,6 +33,188 @@ function is_https_request(): bool {
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// 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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user