diff --git a/admin/integrations.php b/admin/integrations.php index 359f52d..0953bdb 100644 --- a/admin/integrations.php +++ b/admin/integrations.php @@ -23,6 +23,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { $error = __('error_csrf'); } else { + $db->setSetting('enforce_2fa_admins', isset($_POST['enforce_2fa_admins']) ? '1' : '0'); $db->setSetting('trusted_proxy', trim($_POST['trusted_proxy'] ?? '')); $db->setSetting('webhook_enabled', isset($_POST['webhook_enabled']) ? '1' : '0'); $db->setSetting('webhook_url', trim($_POST['webhook_url'] ?? '')); @@ -39,6 +40,7 @@ if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrf $success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).'; } +$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1'; $trustedProxy = $db->getSetting('trusted_proxy', ''); $webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1'; $webhookUrl = $db->getSetting('webhook_url', ''); @@ -80,6 +82,12 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
+
+

Sicherheitsrichtlinie

+

Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.

+ +
+

Reverse-Proxy

IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus X-Forwarded-For für Rate-Limit & Audit verwendet.

diff --git a/admin/security.php b/admin/security.php index afef7b1..d582660 100644 --- a/admin/security.php +++ b/admin/security.php @@ -16,8 +16,10 @@ $appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); $error = ''; $success = ''; +$backupCodes = []; // nur direkt nach Erzeugung gefüllt $hasPassword = !empty($user['password_hash']); $totpEnabled = !empty($user['totp_enabled']); +$setupRequired = isset($_GET['setup_required']); // 2FA aktivieren (Code bestaetigen) if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) { @@ -31,14 +33,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) { } elseif (!Totp::verify($secret, $code)) { $error = 'Code ungültig. Bitte erneut versuchen.'; } else { - $auth->enableTotp($user['id'], $secret); + $backupCodes = $auth->enableTotp($user['id'], $secret); unset($_SESSION['totp_setup_secret']); $totpEnabled = true; - $success = 'Zwei-Faktor-Authentifizierung wurde aktiviert.'; + $user = $auth->getCurrentUser(); + $success = 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!'; } } } +// Recovery-Codes neu erzeugen +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $error = 'Ungültiges Sicherheits-Token'; + } elseif (!empty($user['totp_enabled'])) { + $backupCodes = $auth->regenerateBackupCodes($user['id']); + $user = $auth->getCurrentUser(); + $success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.'; + } +} + // 2FA deaktivieren if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { @@ -88,6 +102,10 @@ input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-ra .btn { width:100%; padding:14px; border:none; border-radius:10px; font-size:15px; font-weight:600; cursor:pointer; margin-top:14px; } .btn-primary { background:#667eea; color:#fff; } .btn-danger { background:#e25555; color:#fff; } .back { display:block; text-align:center; margin-top:20px; color:#667eea; text-decoration:none; font-size:14px; } +.codes-box { background:#fff8e6; border:1px solid #ffe3a3; border-radius:10px; padding:16px; margin-bottom:18px; } +.codes-box strong { font-size:15px; } .codes-box p { color:#8a6d2f; font-size:13px; margin:6px 0 12px; } +.codes { display:grid; grid-template-columns:1fr 1fr; gap:8px; } +.codes span { font-family:monospace; background:#fff; border:1px solid #ffe3a3; border-radius:6px; padding:8px; text-align:center; letter-spacing:2px; font-size:14px; } @@ -95,15 +113,33 @@ input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-ra

🔐 Zwei-Faktor-Authentifizierung

Konto:

+ +
Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.
+
+ +
+ 🔑 Recovery-Codes +

Bewahren Sie diese sicher auf. Jeder Code funktioniert einmal, falls Sie keinen Zugriff auf Ihre App haben.

+
+ +
+
+ +
● Nicht verfügbar

Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.

● Aktiv
-

Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.

+

Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.
+ Verbleibende Recovery-Codes: backupCodesRemaining($user) ?>

+ + + +
diff --git a/admin/users.php b/admin/users.php index 197d918..da5317f 100644 --- a/admin/users.php +++ b/admin/users.php @@ -148,6 +148,14 @@ if (isset($_GET['toggle']) && isset($_GET['token'])) { } else { $error = __('error_csrf'); } } +// 2FA eines Benutzers zurücksetzen (Admin-Hilfe bei verlorenem Authenticator) +if (isset($_GET['reset_2fa']) && isset($_GET['token'])) { + if ($auth->validateCsrfToken($_GET['token'])) { + $auth->disableTotp((int)$_GET['reset_2fa']); + $success = '2FA des Benutzers wurde zurückgesetzt.'; + } else { $error = __('error_csrf'); } +} + $users = $db->fetchAll("SELECT * FROM users ORDER BY name"); $sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name"); $userSiteAccess = []; @@ -304,6 +312,13 @@ $currentPage = 'users'; + + + + + diff --git a/database.sql b/database.sql index 22e5375..a9c1168 100644 --- a/database.sql +++ b/database.sql @@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS `users` ( `microsoft_id` VARCHAR(255) UNIQUE, `totp_secret` VARCHAR(64) NULL, `totp_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `totp_backup_codes` TEXT NULL, `last_login` TIMESTAMP NULL, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -70,6 +71,8 @@ CREATE TABLE IF NOT EXISTS `api_keys` ( `name` VARCHAR(255) NOT NULL, `key_prefix` VARCHAR(16) NOT NULL, `key_hash` VARCHAR(255) NOT NULL, + `scope` VARCHAR(16) NOT NULL DEFAULT 'write', + `rate_limit` INT NOT NULL DEFAULT 0, `created_by` INT, `last_used_at` TIMESTAMP NULL, `is_active` TINYINT(1) NOT NULL DEFAULT 1, @@ -79,6 +82,13 @@ CREATE TABLE IF NOT EXISTS `api_keys` ( INDEX `idx_active` (`is_active`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS `api_key_hits` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `api_key_id` INT NOT NULL, + `hit_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_key_time` (`api_key_id`, `hit_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + CREATE TABLE IF NOT EXISTS `vouchers` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `site_id` INT NOT NULL, diff --git a/includes/Auth.php b/includes/Auth.php index da8fc0f..05371dc 100644 --- a/includes/Auth.php +++ b/includes/Auth.php @@ -101,7 +101,13 @@ class Auth { unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_time']); return false; } - if (!Totp::verify($user['totp_secret'], $code)) { + // Entweder gueltiger TOTP-Code ODER ein Recovery-/Backup-Code + $ok = Totp::verify($user['totp_secret'], $code); + if (!$ok && $this->consumeBackupCode($user, $code)) { + $ok = true; + $this->writeAuditLog($user['id'], 'user_login_backup_code', 'user', $user['id'], 'Login per Recovery-Code'); + } + if (!$ok) { $this->writeAuditLog($user['id'], 'user_login_2fa_failed', 'user', $user['id'], '2FA-Code falsch'); return false; } @@ -112,18 +118,78 @@ class Auth { return true; } - /** 2FA fuer einen Benutzer aktivieren (nach erfolgreicher Code-Verifikation). */ + /** + * 2FA fuer einen Benutzer aktivieren und Recovery-Codes erzeugen. + * @return array Klartext-Recovery-Codes (nur hier einmalig verfuegbar) + */ public function enableTotp($userId, $secret) { - $this->db->query("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE id = ?", [$secret, $userId]); + $codes = $this->generateBackupCodes(); + $hashes = array_map(function ($c) { return hash('sha256', $c); }, $codes); + $this->db->query( + "UPDATE users SET totp_secret = ?, totp_enabled = 1, totp_backup_codes = ? WHERE id = ?", + [$secret, json_encode($hashes), $userId] + ); $this->writeAuditLog($userId, 'totp_enabled', 'user', $userId, '2FA aktiviert'); + return $codes; } /** 2FA fuer einen Benutzer deaktivieren. */ public function disableTotp($userId) { - $this->db->query("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?", [$userId]); + $this->db->query( + "UPDATE users SET totp_secret = NULL, totp_enabled = 0, totp_backup_codes = NULL WHERE id = ?", + [$userId] + ); $this->writeAuditLog($userId, 'totp_disabled', 'user', $userId, '2FA deaktiviert'); } + /** Neue Recovery-Codes erzeugen (8 Stueck, Format XXXX-XXXX). */ + public function generateBackupCodes($count = 8) { + $codes = []; + for ($i = 0; $i < $count; $i++) { + $raw = strtoupper(bin2hex(random_bytes(4))); // 8 Hex-Zeichen + $codes[] = substr($raw, 0, 4) . '-' . substr($raw, 4, 4); + } + return $codes; + } + + /** Recovery-Codes neu erzeugen und speichern; gibt Klartext zurueck. */ + public function regenerateBackupCodes($userId) { + $codes = $this->generateBackupCodes(); + $hashes = array_map(function ($c) { return hash('sha256', $c); }, $codes); + $this->db->query("UPDATE users SET totp_backup_codes = ? WHERE id = ?", [json_encode($hashes), $userId]); + $this->writeAuditLog($userId, 'totp_backup_regenerated', 'user', $userId, 'Recovery-Codes neu erzeugt'); + return $codes; + } + + /** Anzahl noch nicht verbrauchter Recovery-Codes. */ + public function backupCodesRemaining($user) { + $list = json_decode($user['totp_backup_codes'] ?? '[]', true); + return is_array($list) ? count($list) : 0; + } + + /** Prueft & verbraucht einen Recovery-Code (konstante Zeit). */ + private function consumeBackupCode($user, $code) { + $code = strtoupper(trim($code)); + $list = json_decode($user['totp_backup_codes'] ?? '[]', true); + if (!is_array($list) || empty($list)) { + return false; + } + $hash = hash('sha256', $code); + $matched = false; + $remaining = []; + foreach ($list as $h) { + if (!$matched && hash_equals($h, $hash)) { + $matched = true; // diesen verbrauchen (nicht behalten) + } else { + $remaining[] = $h; + } + } + if ($matched) { + $this->db->query("UPDATE users SET totp_backup_codes = ? WHERE id = ?", [json_encode($remaining), $user['id']]); + } + return $matched; + } + public function writeAuditLog($userId, $action, $entityType = null, $entityId = null, $details = null) { try { $this->db->execute( @@ -334,6 +400,20 @@ class Auth { header('Location: /index.php?error=access_denied'); exit; } + // Optionale Richtlinie: 2FA fuer Admins erzwingen. Admins ohne aktives + // 2FA werden zur Einrichtung umgeleitet (security.php nutzt requireLogin, + // daher keine Endlosschleife). + try { + if ((string)$this->db->getSetting('enforce_2fa_admins', '0') === '1') { + $user = $this->getCurrentUser(); + $script = basename($_SERVER['SCRIPT_NAME'] ?? ''); + if ($user && !empty($user['password_hash']) && empty($user['totp_enabled']) + && $script !== 'security.php') { + header('Location: security.php?setup_required=1'); + exit; + } + } + } catch (\Exception $e) { /* Richtlinie nie blockierend */ } } // Login erforderlich diff --git a/login.php b/login.php index ca78bd0..341b1b6 100644 --- a/login.php +++ b/login.php @@ -169,13 +169,15 @@ try {

- Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein. + Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein + – oder einen Ihrer Recovery-Codes.

- - + +
diff --git a/updater/migrations/0003_maturity_features.sql b/updater/migrations/0003_maturity_features.sql new file mode 100644 index 0000000..f11800f --- /dev/null +++ b/updater/migrations/0003_maturity_features.sql @@ -0,0 +1,16 @@ +-- Updater-Migration: Reife-Funktionen +-- * 2FA Recovery-/Backup-Codes +-- * API-Scopes & Rate-Limit pro Schlüssel +-- Idempotent; "duplicate column"/"already exists" werden ignoriert. + +ALTER TABLE `users` ADD COLUMN `totp_backup_codes` TEXT NULL; + +ALTER TABLE `api_keys` ADD COLUMN `scope` VARCHAR(16) NOT NULL DEFAULT 'write'; +ALTER TABLE `api_keys` ADD COLUMN `rate_limit` INT NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS `api_key_hits` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `api_key_id` INT NOT NULL, + `hit_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_key_time` (`api_key_id`, `hit_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;