Security Hardening: Proaktive Kontosperren, Fail2Ban-Integration, SecurityLogger, Alerting und API-Schutz

This commit is contained in:
Deploymentcenter Bot
2026-09-08 11:45:04 +02:00
parent 087f4392a9
commit edea46e342
17 changed files with 927 additions and 169 deletions
+27 -1
View File
@@ -57,6 +57,14 @@ final class ApiAuth
?string $environment = null,
bool $allowSession = true
): ?array {
// 0. IP-Sperre pruefen
$ip = Http::clientIp();
$ipLockout = Auth::lockoutSeconds($db, $ip);
if ($ipLockout > 0) {
SecurityLogger::ipLocked($ip, (int)Config::get('security.max_ip_attempts', Auth::DEFAULT_MAX_IP_ATTEMPTS), $ipLockout, ['context' => 'api']);
Http::fail(429, 'locked_out', 'IP-Adresse aufgrund wiederholter Fehlversuche vorübergehend gesperrt.', null, ['retry_after_seconds' => $ipLockout]);
}
$presented = Http::bearerToken();
// 1. Shared Key
@@ -101,9 +109,27 @@ final class ApiAuth
];
}
SecurityLogger::apiAuthFail($ip, Http::path(), 'token_rejected', ['scope' => $scope]);
// Drosselung bei wiederholtem Vorlegen ungueltiger Tokens
$failLimiter = new RateLimiter($db, 20, 300, 'api_token_failures');
if (!$failLimiter->check($ip)) {
SecurityLogger::log('API_BRUTE_FORCE', $ip, [
'path' => Http::path(),
'reason' => 'too_many_invalid_tokens',
]);
Auth::triggerSecurityAlert($db, 'security.attack', [
'ip' => $ip,
'details' => 'Mehr als 20 ungültige API-Tokens innerhalb von 5 Minuten übermittelt.',
'target' => Http::path(),
'type' => 'API-Token-Brute-Force',
]);
Http::fail(429, 'rate_limited', 'Zu viele fehlerhafte Authentifizierungsversuche. Bitte 5 Minuten warten.');
}
Logger::warning('Token abgelehnt', [
'scope' => $scope,
'ip' => Http::clientIp(),
'ip' => $ip,
'path' => Http::path(),
]);
}
+238 -78
View File
@@ -4,16 +4,19 @@ declare(strict_types=1);
namespace Deploymentcenter\Core;
use Deploymentcenter\Modules\Notify\WebhookDispatcher;
use PDO;
/**
* Session- und Anmeldeverwaltung fuer das WebUI.
* Session- und Anmeldeverwaltung fuer das WebUI und die Setup-API.
*
* Haerteung gegenueber der Erstfassung:
* Sicherheitsfunktionen:
* - Session-Cookie mit HttpOnly, SameSite=Lax und Secure (bei HTTPS)
* - session_regenerate_id() nach erfolgreicher Anmeldung (Session Fixation)
* - Leerlauf- und Absolut-Timeout
* - Anmeldeversuche werden gezaehlt und pro IP gedrosselt
* - Drosselung und Sperre nach IP (Fail2Ban-kompatibel ueber SecurityLogger)
* - Proaktive zeitbasierte Kontosperre nach N Fehlversuchen (pro Benutzer)
* - Alarmierung bei Angriffen (Rocket.Chat / Webhooks mit Flut-Schutz)
*/
final class Auth
{
@@ -23,11 +26,17 @@ final class Auth
/** Absolutes Session-Maximum in Sekunden (12 Stunden). */
private const ABSOLUTE_TIMEOUT = 43200;
/** Fehlversuche pro IP, bevor gesperrt wird. */
private const MAX_ATTEMPTS = 10;
/** Fehlversuche pro IP, bevor IP-Sperre greift. */
public const DEFAULT_MAX_IP_ATTEMPTS = 10;
/** Sperrfenster in Sekunden. */
private const LOCKOUT_WINDOW = 900;
/** IP-Sperrfenster in Sekunden (15 Minuten). */
public const DEFAULT_IP_LOCKOUT_WINDOW = 900;
/** Fehlversuche pro Benutzerkonto, bevor das Konto gesperrt wird. */
public const DEFAULT_MAX_ACCOUNT_ATTEMPTS = 5;
/** Kontosperrdauer in Sekunden (15 Minuten). */
public const DEFAULT_ACCOUNT_LOCKOUT_SECONDS = 900;
public static function startSession(): void
{
@@ -84,13 +93,95 @@ final class Auth
}
/**
* Prueft Zugangsdaten, ohne eine Session anzufassen.
*
* Getrennt von login(), weil der Setup-Weg dieselbe Pruefung braucht,
* aber ein kurzlebiges Token statt eines Session-Cookies ausstellt.
* Drosselung, Timing-Angleichung und Rehash gelten dort genauso - sie
* hier zu wiederholen hiesse, zwei Anmeldewege mit zwei Haertungsgraden
* zu haben.
* Verbleibende Kontosperrzeit in Sekunden, oder 0 wenn nicht gesperrt.
*/
public static function accountLockoutSeconds(PDO $db, string $username): int
{
$username = trim($username);
if ($username === '') {
return 0;
}
try {
$stmt = $db->prepare('SELECT locked_until FROM dc_users WHERE username = :u LIMIT 1');
$stmt->execute([':u' => $username]);
$lockedUntil = $stmt->fetchColumn();
if ($lockedUntil === false || $lockedUntil === null || $lockedUntil === '') {
return 0;
}
$ts = strtotime((string)$lockedUntil . ' UTC');
if ($ts === false) {
return 0;
}
$diff = $ts - time();
return $diff > 0 ? $diff : 0;
} catch (\Throwable $e) {
// Spalte fehlt eventuell vor Migration 018
return 0;
}
}
/**
* Verbleibende IP-Sperrzeit in Sekunden, oder 0 wenn nicht gesperrt.
*/
public static function lockoutSeconds(PDO $db, string $ip): int
{
$maxAttempts = (int)Config::get('security.max_ip_attempts', self::DEFAULT_MAX_IP_ATTEMPTS);
$window = (int)Config::get('security.ip_lockout_window', self::DEFAULT_IP_LOCKOUT_WINDOW);
try {
$stmt = $db->prepare('
SELECT COUNT(*) AS failures, MAX(attempted_at) AS last_attempt
FROM dc_login_attempts
WHERE ip = :ip
AND success = 0
AND attempted_at > (UTC_TIMESTAMP() - INTERVAL ' . $window . ' SECOND)
');
$stmt->execute([':ip' => self::packIp($ip)]);
$row = $stmt->fetch();
if (!is_array($row) || (int)$row['failures'] < $maxAttempts) {
return 0;
}
$last = isset($row['last_attempt']) ? strtotime((string)$row['last_attempt'] . ' UTC') : false;
if ($last === false) {
return $window;
}
$remaining = $window - (time() - $last);
return $remaining > 0 ? $remaining : 0;
} catch (\Throwable $e) {
Logger::warning('IP-Lockout-Pruefung nicht moeglich', ['error' => $e->getMessage()]);
return 0;
}
}
/**
* Entsperrt ein Benutzerkonto administrativ.
*/
public static function unlockAccount(PDO $db, int $userId): bool
{
try {
$stmt = $db->prepare('UPDATE dc_users SET failed_login_count = 0, locked_until = NULL WHERE id = :id');
$stmt->execute([':id' => $userId]);
$affected = $stmt->rowCount();
if ($affected > 0) {
Logger::info('Benutzerkonto administrativ entsperrt', ['user_id' => $userId]);
}
return $affected > 0;
} catch (\Throwable $e) {
Logger::error('Entsperren fehlgeschlagen', ['error' => $e->getMessage()]);
return false;
}
}
/**
* Prueft Zugangsdaten unter Beachtung von IP- und Account-Sperren.
*
* @return array<string,mixed>|null Der Benutzerdatensatz, oder null
*/
@@ -98,8 +189,17 @@ final class Auth
{
$ip = Http::clientIp();
if (self::isLockedOut($db, $ip)) {
Logger::warning('Anmeldung gesperrt (zu viele Fehlversuche)', ['ip' => $ip, 'username' => $username]);
// 1. IP-Sperre pruefen
$ipLockout = self::lockoutSeconds($db, $ip);
if ($ipLockout > 0) {
SecurityLogger::ipLocked($ip, (int)Config::get('security.max_ip_attempts', self::DEFAULT_MAX_IP_ATTEMPTS), $ipLockout, ['user' => $username]);
return null;
}
// 2. Account-Sperre pruefen
$accLockout = self::accountLockoutSeconds($db, $username);
if ($accLockout > 0) {
SecurityLogger::accountLocked($ip, $username, $accLockout);
return null;
}
@@ -108,29 +208,55 @@ final class Auth
$user = $stmt->fetch();
$found = is_array($user) && isset($user['password_hash']);
// Auch ohne Treffer wird ein Hash berechnet, damit die Antwortzeit
// keinen Rueckschluss auf die Existenz des Kontos erlaubt.
$hash = $found ? (string)$user['password_hash'] : self::dummyHash();
$verified = password_verify($password, $hash);
if (!$verified || !$found) {
self::recordAttempt($db, $ip, $username, false);
if ($found) {
// Konsekutive Fehlversuche fuer dieses Konto hochzaehlen
self::handleAccountFailedAttempt($db, $user, $ip, $username);
} else {
SecurityLogger::authFail($ip, $username, 'user_not_found');
}
// Pruefen, ob die IP dadurch soeben gesperrt wurde
$newIpLockout = self::lockoutSeconds($db, $ip);
if ($newIpLockout > 0) {
SecurityLogger::ipLocked($ip, (int)Config::get('security.max_ip_attempts', self::DEFAULT_MAX_IP_ATTEMPTS), $newIpLockout);
self::triggerSecurityAlert($db, 'security.attack', [
'ip' => $ip,
'details' => sprintf(
'IP nach %d Fehlversuchen fuer %d Minuten gesperrt.',
(int)Config::get('security.max_ip_attempts', self::DEFAULT_MAX_IP_ATTEMPTS),
(int)ceil($newIpLockout / 60)
),
'target' => 'WebUI / Login',
'type' => 'IP-Sperre',
]);
}
return null;
}
// Ein deaktiviertes Konto bleibt bestehen, damit Protokolle weiter
// darauf verweisen koennen - anmelden darf es sich nicht.
// Deaktiviertes Konto
if ((int)($user['disabled'] ?? 0) === 1) {
self::recordAttempt($db, $ip, $username, false);
Logger::warning('Anmeldung eines deaktivierten Kontos abgelehnt', [
'username' => $username,
'ip' => $ip,
]);
SecurityLogger::authFail($ip, $username, 'account_disabled');
return null;
}
// Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben.
// Erfolgreicher Login: Fehlversuche fuer Konto und IP zuruecksetzen
try {
$reset = $db->prepare('UPDATE dc_users SET failed_login_count = 0, locked_until = NULL WHERE id = :id');
$reset->execute([':id' => $user['id']]);
} catch (\Throwable $e) {
// Spalten fehlen evtl. vor Migration
}
// Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$upd = $db->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
$upd->execute([':h' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['id']]);
@@ -144,10 +270,6 @@ final class Auth
/**
* Rolle eines Datensatzes aus dc_users.
*
* Faellt auf 'admin' zurueck, solange Migration 012 nicht gelaufen ist -
* sonst waere nach dem Einspielen des Codes und vor der Migration niemand
* mehr anmeldeberechtigt.
*/
public static function roleOf(?array $user): string
{
@@ -168,11 +290,9 @@ final class Auth
return false;
}
// Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu
// suchen. Waere die Anmeldung hier erlaubt, brauchte man das Konto
// gar nicht zu trennen: wer es auf einem Zielsystem eingibt, haette
// damit auch Zugriff auf Tokens, Lizenzen und Monitore.
// Ein Installationskonto hat in der Verwaltungsoberflaeche nichts zu suchen
if (self::roleOf($user) === 'installer') {
SecurityLogger::authFail(Http::clientIp(), $username, 'installer_role_not_allowed_for_webui');
Logger::warning('WebUI-Anmeldung eines Installationskontos abgelehnt', [
'username' => $username,
'ip' => Http::clientIp(),
@@ -193,14 +313,96 @@ final class Auth
return true;
}
private static function handleAccountFailedAttempt(PDO $db, array $user, string $ip, string $username): void
{
$maxAttempts = (int)Config::get('security.max_account_attempts', self::DEFAULT_MAX_ACCOUNT_ATTEMPTS);
$lockoutMinutes = (int)Config::get('security.account_lockout_minutes', 15);
$currentFailures = (int)($user['failed_login_count'] ?? 0) + 1;
try {
if ($currentFailures >= $maxAttempts) {
$lockedUntil = gmdate('Y-m-d H:i:s', time() + ($lockoutMinutes * 60));
$stmt = $db->prepare('
UPDATE dc_users
SET failed_login_count = :count, locked_until = :locked
WHERE id = :id
');
$stmt->execute([
':count' => $currentFailures,
':locked' => $lockedUntil,
':id' => $user['id'],
]);
SecurityLogger::accountLocked($ip, $username, $lockoutMinutes * 60, [
'failures' => $currentFailures,
]);
self::triggerSecurityAlert($db, 'security.lockout', [
'username' => $username,
'ip' => $ip,
'duration_minutes' => $lockoutMinutes,
]);
} else {
$stmt = $db->prepare('
UPDATE dc_users
SET failed_login_count = :count
WHERE id = :id
');
$stmt->execute([
':count' => $currentFailures,
':id' => $user['id'],
]);
SecurityLogger::authFail($ip, $username, 'invalid_password', [
'failures' => $currentFailures,
'remaining_attempts' => max(0, $maxAttempts - $currentFailures),
]);
}
} catch (\Throwable $e) {
SecurityLogger::authFail($ip, $username, 'invalid_password');
}
}
public static function triggerSecurityAlert(PDO $db, string $event, array $payload): void
{
$target = (string)($payload['username'] ?? $payload['ip'] ?? 'general');
$cooldownKey = 'sec_cooldown_' . substr(hash('sha256', $event . ':' . $target), 0, 16);
try {
$stmt = $db->prepare('SELECT svalue FROM dc_settings WHERE skey = :k LIMIT 1');
$stmt->execute([':k' => $cooldownKey]);
$lastAlert = $stmt->fetchColumn();
if ($lastAlert !== false && $lastAlert !== null && is_numeric($lastAlert)) {
$elapsed = time() - (int)$lastAlert;
if ($elapsed < 900) { // 15 Minuten Alarmierungs-Sperre fuer selbes Ziel
return;
}
}
$upd = $db->prepare('
INSERT INTO dc_settings (skey, svalue)
VALUES (:k, :v)
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
');
$upd->execute([':k' => $cooldownKey, ':v' => (string)time()]);
} catch (\Throwable $e) {
// Bei Tabellenfehler fortfahren
}
try {
WebhookDispatcher::dispatch($db, $event, $payload);
} catch (\Throwable $e) {
Logger::error('Sicherheits-Alarmierung fehlgeschlagen', ['error' => $e->getMessage()]);
}
}
private static function touchLastLogin(PDO $db, int $userId): void
{
try {
$stmt = $db->prepare('UPDATE dc_users SET last_login_at = UTC_TIMESTAMP() WHERE id = :id');
$stmt->execute([':id' => $userId]);
} catch (\Throwable $e) {
// Spalte fehlt (Migration 012 noch nicht gelaufen) - kein Grund,
// die Anmeldung scheitern zu lassen.
Logger::warning('last_login_at nicht gesetzt', ['error' => $e->getMessage()]);
}
}
@@ -226,48 +428,6 @@ final class Auth
session_destroy();
}
/**
* Verbleibende Sperrzeit in Sekunden, oder 0 wenn nicht gesperrt.
*/
public static function lockoutSeconds(PDO $db, string $ip): int
{
try {
$stmt = $db->prepare('
SELECT COUNT(*) AS failures, MAX(attempted_at) AS last_attempt
FROM dc_login_attempts
WHERE ip = :ip
AND success = 0
AND attempted_at > (UTC_TIMESTAMP() - INTERVAL ' . self::LOCKOUT_WINDOW . ' SECOND)
');
$stmt->execute([':ip' => self::packIp($ip)]);
$row = $stmt->fetch();
if (!is_array($row) || (int)$row['failures'] < self::MAX_ATTEMPTS) {
return 0;
}
$last = isset($row['last_attempt']) ? strtotime((string)$row['last_attempt'] . ' UTC') : false;
if ($last === false) {
return self::LOCKOUT_WINDOW;
}
$remaining = self::LOCKOUT_WINDOW - (time() - $last);
return $remaining > 0 ? $remaining : 0;
} catch (\Throwable $e) {
// Fehlt die Tabelle (Migration noch nicht gelaufen), darf die
// Anmeldung nicht blockiert werden.
Logger::warning('Lockout-Pruefung nicht moeglich', ['error' => $e->getMessage()]);
return 0;
}
}
// ------------------------------------------------------------------
private static function isLockedOut(PDO $db, string $ip): bool
{
return self::lockoutSeconds($db, $ip) > 0;
}
private static function recordAttempt(PDO $db, string $ip, string $username, bool $success): void
{
try {
@@ -281,7 +441,7 @@ final class Auth
':success' => $success ? 1 : 0,
]);
// Bei Erfolg die Fehlversuche dieser IP zuruecksetzen.
// Bei Erfolg die Fehlversuche dieser IP zuruecksetzen
if ($success) {
$del = $db->prepare('DELETE FROM dc_login_attempts WHERE ip = :ip AND success = 0');
$del->execute([':ip' => self::packIp($ip)]);
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
use PDO;
/**
* Universelle Zaehler-Drosselung (Rate Limiter) pro IP, Zeitfenster und Bucket.
*
* Verwaltet atomare Zaehler in dc_rate_limits ueber INSERT ... ON DUPLICATE KEY UPDATE.
* Schlaegt die Pruefung fehl (z. B. waehrend einer laufenden Migration), wird
* die Anfrage durchgelassen, um den Dienst nicht zu blockieren.
*/
final class RateLimiter
{
private PDO $db;
private int $limit;
private int $windowSeconds;
private string $bucket;
public function __construct(PDO $db, int $limit = 60, int $windowSeconds = 60, string $bucket = 'default')
{
$this->db = $db;
$this->limit = max(1, $limit);
$this->windowSeconds = max(1, $windowSeconds);
$this->bucket = $bucket;
}
/**
* Zaehlt einen Zugriff und meldet, ob er erlaubt ist.
*/
public function check(string $ip): bool
{
$packed = @inet_pton($ip);
if ($packed === false) {
return true;
}
$now = time();
$windowStart = gmdate('Y-m-d H:i:s', $now - ($now % $this->windowSeconds));
try {
$stmt = $this->db->prepare('
INSERT INTO dc_rate_limits (ip, bucket, window_start, request_count)
VALUES (:ip, :bucket, :window_start, 1)
ON DUPLICATE KEY UPDATE request_count = request_count + 1
');
$stmt->execute([
':ip' => $packed,
':bucket' => mb_substr($this->bucket, 0, 64),
':window_start' => $windowStart,
]);
$read = $this->db->prepare('
SELECT request_count FROM dc_rate_limits
WHERE ip = :ip AND bucket = :bucket AND window_start = :window_start
');
$read->execute([
':ip' => $packed,
':bucket' => mb_substr($this->bucket, 0, 64),
':window_start' => $windowStart,
]);
$count = (int)$read->fetchColumn();
$allowed = $count <= $this->limit;
if (!$allowed) {
SecurityLogger::rateLimited($ip, $this->bucket, $this->limit, ['count' => $count]);
}
return $allowed;
} catch (\Throwable $e) {
// Fallback auf historische Lizenz-Tabelle, falls dc_rate_limits noch nicht existiert
return $this->fallbackCheck($ip, $packed, $windowStart);
}
}
/**
* Ermittelt die verbleibenden Anfragen im aktuellen Fenster.
*/
public function remaining(string $ip): int
{
$packed = @inet_pton($ip);
if ($packed === false) {
return $this->limit;
}
$now = time();
$windowStart = gmdate('Y-m-d H:i:s', $now - ($now % $this->windowSeconds));
try {
$stmt = $this->db->prepare('
SELECT request_count FROM dc_rate_limits
WHERE ip = :ip AND bucket = :bucket AND window_start = :window_start
');
$stmt->execute([
':ip' => $packed,
':bucket' => mb_substr($this->bucket, 0, 64),
':window_start' => $windowStart,
]);
$count = (int)$stmt->fetchColumn();
return max(0, $this->limit - $count);
} catch (\Throwable $e) {
return $this->limit;
}
}
/**
* Bereinigt aeltere Eintraege.
*/
public function purge(int $olderThanSeconds = 3600): int
{
try {
$seconds = max(60, $olderThanSeconds);
$stmt = $this->db->prepare('
DELETE FROM dc_rate_limits
WHERE window_start < (UTC_TIMESTAMP() - INTERVAL ' . $seconds . ' SECOND)
');
$stmt->execute();
return $stmt->rowCount();
} catch (\Throwable $e) {
return 0;
}
}
private function fallbackCheck(string $ip, string $packed, string $windowStart): bool
{
try {
$key = $this->bucket === 'default' ? $packed : substr(hash('sha256', $this->bucket . $ip, true), 0, 16);
$stmt = $this->db->prepare('
INSERT INTO license_api_rate_limit (ip, window_start, request_count)
VALUES (:ip, :window_start, 1)
ON DUPLICATE KEY UPDATE request_count = request_count + 1
');
$stmt->execute([':ip' => $key, ':window_start' => $windowStart]);
$read = $this->db->prepare('
SELECT request_count FROM license_api_rate_limit
WHERE ip = :ip AND window_start = :window_start
');
$read->execute([':ip' => $key, ':window_start' => $windowStart]);
$count = (int)$read->fetchColumn();
return $count <= $this->limit;
} catch (\Throwable $e) {
Logger::warning('Rate-Limit-Pruefung nicht moeglich', ['error' => $e->getMessage()]);
return true;
}
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
/**
* Dedizierter Sicherheits-Logger fuer Authentifizierungs- und Schutzereignisse.
*
* Schreibt standardmaessig in eine feste Datei (var/log/security.log), damit
* Daemons wie Fail2Ban den Stream zuverlaessig ueberwachen koennen, ohne dass
* rotierende Dateinamen die Erkennung unterbrechen.
*
* Format jeder Zeile:
* [YYYY-MM-DD HH:MM:SS] [SECURITY] [EVENT_TYPE] ip=<IP> key="val" ...
*/
final class SecurityLogger
{
private static ?string $logFile = null;
public static function authFail(string $ip, string $username, string $reason, array $extra = []): void
{
self::log('AUTH_FAIL', $ip, array_merge([
'user' => $username,
'reason' => $reason,
], $extra));
}
public static function accountLocked(string $ip, string $username, int $durationSeconds, array $extra = []): void
{
self::log('ACCOUNT_LOCKED', $ip, array_merge([
'user' => $username,
'duration' => $durationSeconds,
'reason' => 'too_many_failed_logins',
], $extra));
}
public static function ipLocked(string $ip, int $failures, int $durationSeconds, array $extra = []): void
{
self::log('IP_LOCKED', $ip, array_merge([
'failures' => $failures,
'duration' => $durationSeconds,
], $extra));
}
public static function apiAuthFail(string $ip, string $path, string $reason, array $extra = []): void
{
self::log('API_AUTH_FAIL', $ip, array_merge([
'path' => $path,
'reason' => $reason,
], $extra));
}
public static function rateLimited(string $ip, string $bucket, int $limit, array $extra = []): void
{
self::log('RATE_LIMITED', $ip, array_merge([
'bucket' => $bucket,
'limit' => $limit,
], $extra));
}
public static function log(string $eventType, string $ip, array $details = []): void
{
$timestamp = gmdate('Y-m-d H:i:s');
$ip = trim($ip) !== '' ? trim($ip) : '127.0.0.1';
$parts = [];
$parts[] = 'ip=' . $ip;
foreach ($details as $key => $val) {
if ($val === null) {
continue;
}
if (is_bool($val)) {
$parts[] = $key . '=' . ($val ? 'true' : 'false');
} elseif (is_numeric($val)) {
$parts[] = $key . '=' . $val;
} else {
$escaped = str_replace(['"', "\n", "\r"], ['\"', ' ', ' '], (string)$val);
$parts[] = sprintf('%s="%s"', $key, $escaped);
}
}
$line = sprintf(
"[%s] [SECURITY] [%s] %s\n",
$timestamp,
strtoupper($eventType),
implode(' ', $parts)
);
$path = self::filePath();
if ($path !== null) {
@file_put_contents($path, $line, FILE_APPEND | LOCK_EX);
}
// Syslog bei Bedarf
if ((bool)Config::get('security.use_syslog', false)) {
@openlog('deploymentcenter', LOG_PID, LOG_AUTH);
@syslog(LOG_WARNING, rtrim($line));
}
// Parallel im Standard-Anwendungslog spiegeln
Logger::warning(sprintf('Security: %s', $eventType), array_merge(['ip' => $ip], $details));
}
private static function filePath(): ?string
{
if (self::$logFile !== null) {
return self::$logFile === '' ? null : self::$logFile;
}
$custom = (string)Config::get('security.log_file', '');
if ($custom !== '') {
self::$logFile = $custom;
return self::$logFile;
}
$dir = defined('DC_VAR') ? DC_VAR . '/log' : null;
if ($dir === null) {
self::$logFile = '';
return null;
}
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
self::$logFile = '';
return null;
}
self::$logFile = $dir . '/security.log';
return self::$logFile;
}
}