2FA-Reife: Recovery-Codes, Enforce-Policy für Admins, Admin-Reset

- Recovery-/Backup-Codes (8x, einmalig nutzbar) bei Aktivierung + Regenerieren;
  Login akzeptiert TOTP ODER Recovery-Code
- Setting enforce_2fa_admins: Admins ohne 2FA werden zur Einrichtung geleitet
- Admin kann 2FA eines Nutzers zurücksetzen (admin/users.php)
- Schema 0003 (users.totp_backup_codes); security.php zeigt Codes & Restanzahl
This commit is contained in:
Claude 2026-06-05 20:45:28 +00:00
parent dce10a8a08
commit ba121d60e6
No known key found for this signature in database
7 changed files with 179 additions and 12 deletions

View file

@ -23,6 +23,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf'); $error = __('error_csrf');
} else { } else {
$db->setSetting('enforce_2fa_admins', isset($_POST['enforce_2fa_admins']) ? '1' : '0');
$db->setSetting('trusted_proxy', trim($_POST['trusted_proxy'] ?? '')); $db->setSetting('trusted_proxy', trim($_POST['trusted_proxy'] ?? ''));
$db->setSetting('webhook_enabled', isset($_POST['webhook_enabled']) ? '1' : '0'); $db->setSetting('webhook_enabled', isset($_POST['webhook_enabled']) ? '1' : '0');
$db->setSetting('webhook_url', trim($_POST['webhook_url'] ?? '')); $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).'; $success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).';
} }
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
$trustedProxy = $db->getSetting('trusted_proxy', ''); $trustedProxy = $db->getSetting('trusted_proxy', '');
$webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1'; $webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1';
$webhookUrl = $db->getSetting('webhook_url', ''); $webhookUrl = $db->getSetting('webhook_url', '');
@ -80,6 +82,12 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<div class="card">
<h2>Sicherheitsrichtlinie</h2>
<p class="muted">Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.</p>
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label>
</div>
<div class="card"> <div class="card">
<h2>Reverse-Proxy</h2> <h2>Reverse-Proxy</h2>
<p class="muted">IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus <code>X-Forwarded-For</code> für Rate-Limit & Audit verwendet.</p> <p class="muted">IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus <code>X-Forwarded-For</code> für Rate-Limit & Audit verwendet.</p>

View file

@ -16,8 +16,10 @@ $appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$error = ''; $error = '';
$success = ''; $success = '';
$backupCodes = []; // nur direkt nach Erzeugung gefüllt
$hasPassword = !empty($user['password_hash']); $hasPassword = !empty($user['password_hash']);
$totpEnabled = !empty($user['totp_enabled']); $totpEnabled = !empty($user['totp_enabled']);
$setupRequired = isset($_GET['setup_required']);
// 2FA aktivieren (Code bestaetigen) // 2FA aktivieren (Code bestaetigen)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) { 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)) { } elseif (!Totp::verify($secret, $code)) {
$error = 'Code ungültig. Bitte erneut versuchen.'; $error = 'Code ungültig. Bitte erneut versuchen.';
} else { } else {
$auth->enableTotp($user['id'], $secret); $backupCodes = $auth->enableTotp($user['id'], $secret);
unset($_SESSION['totp_setup_secret']); unset($_SESSION['totp_setup_secret']);
$totpEnabled = true; $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 // 2FA deaktivieren
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { 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 { 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; } .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; } .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; }
</style> </style>
</head> </head>
<body> <body>
@ -95,15 +113,33 @@ input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-ra
<h1>🔐 Zwei-Faktor-Authentifizierung</h1> <h1>🔐 Zwei-Faktor-Authentifizierung</h1>
<p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p> <p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p>
<?php if ($setupRequired && !$totpEnabled): ?>
<div class="alert alert-error">Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.</div>
<?php endif; ?>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?> <?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?> <?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
<?php if (!empty($backupCodes)): ?>
<div class="codes-box">
<strong>🔑 Recovery-Codes</strong>
<p>Bewahren Sie diese sicher auf. Jeder Code funktioniert <em>einmal</em>, falls Sie keinen Zugriff auf Ihre App haben.</p>
<div class="codes">
<?php foreach ($backupCodes as $c): ?><span><?= htmlspecialchars($c) ?></span><?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!$hasPassword): ?> <?php if (!$hasPassword): ?>
<div class="status off"> Nicht verfügbar</div> <div class="status off"> Nicht verfügbar</div>
<p class="sub">Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.</p> <p class="sub">Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.</p>
<?php elseif ($totpEnabled): ?> <?php elseif ($totpEnabled): ?>
<div class="status on"> Aktiv</div> <div class="status on"> Aktiv</div>
<p class="sub">Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.</p> <p class="sub">Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.<br>
Verbleibende Recovery-Codes: <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p>
<form method="post" style="margin-bottom:10px;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<button type="submit" name="regen_codes" class="btn btn-secondary" style="background:#eef0ff;color:#5a63d6;width:100%;">Recovery-Codes neu erzeugen</button>
</form>
<form method="post" onsubmit="return confirm('2FA wirklich deaktivieren?');"> <form method="post" onsubmit="return confirm('2FA wirklich deaktivieren?');">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<button type="submit" name="disable_totp" class="btn btn-danger">2FA deaktivieren</button> <button type="submit" name="disable_totp" class="btn btn-danger">2FA deaktivieren</button>

View file

@ -148,6 +148,14 @@ if (isset($_GET['toggle']) && isset($_GET['token'])) {
} else { $error = __('error_csrf'); } } 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"); $users = $db->fetchAll("SELECT * FROM users ORDER BY name");
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name"); $sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
$userSiteAccess = []; $userSiteAccess = [];
@ -304,6 +312,13 @@ $currentPage = 'users';
<i class="fas fa-key"></i> <i class="fas fa-key"></i>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($user['totp_enabled'])): ?>
<a href="?reset_2fa=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-secondary btn-sm" title="2FA zurücksetzen"
onclick="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
<i class="fas fa-user-shield"></i>
</a>
<?php endif; ?>
<a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>" <a href="?delete=<?= $user['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"
onclick="return confirm('Benutzer wirklich löschen?')"> onclick="return confirm('Benutzer wirklich löschen?')">

View file

@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`microsoft_id` VARCHAR(255) UNIQUE, `microsoft_id` VARCHAR(255) UNIQUE,
`totp_secret` VARCHAR(64) NULL, `totp_secret` VARCHAR(64) NULL,
`totp_enabled` TINYINT(1) NOT NULL DEFAULT 0, `totp_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`totp_backup_codes` TEXT NULL,
`last_login` TIMESTAMP NULL, `last_login` TIMESTAMP NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE 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, `name` VARCHAR(255) NOT NULL,
`key_prefix` VARCHAR(16) NOT NULL, `key_prefix` VARCHAR(16) NOT NULL,
`key_hash` VARCHAR(255) 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, `created_by` INT,
`last_used_at` TIMESTAMP NULL, `last_used_at` TIMESTAMP NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1, `is_active` TINYINT(1) NOT NULL DEFAULT 1,
@ -79,6 +82,13 @@ CREATE TABLE IF NOT EXISTS `api_keys` (
INDEX `idx_active` (`is_active`) INDEX `idx_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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` ( CREATE TABLE IF NOT EXISTS `vouchers` (
`id` INT PRIMARY KEY AUTO_INCREMENT, `id` INT PRIMARY KEY AUTO_INCREMENT,
`site_id` INT NOT NULL, `site_id` INT NOT NULL,

View file

@ -101,7 +101,13 @@ class Auth {
unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_time']); unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_time']);
return false; 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'); $this->writeAuditLog($user['id'], 'user_login_2fa_failed', 'user', $user['id'], '2FA-Code falsch');
return false; return false;
} }
@ -112,18 +118,78 @@ class Auth {
return true; 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) { 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'); $this->writeAuditLog($userId, 'totp_enabled', 'user', $userId, '2FA aktiviert');
return $codes;
} }
/** 2FA fuer einen Benutzer deaktivieren. */ /** 2FA fuer einen Benutzer deaktivieren. */
public function disableTotp($userId) { 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'); $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) { public function writeAuditLog($userId, $action, $entityType = null, $entityId = null, $details = null) {
try { try {
$this->db->execute( $this->db->execute(
@ -334,6 +400,20 @@ class Auth {
header('Location: /index.php?error=access_denied'); header('Location: /index.php?error=access_denied');
exit; 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 // Login erforderlich

View file

@ -169,13 +169,15 @@ try {
<?php if ($show2fa): ?> <?php if ($show2fa): ?>
<form method="post"> <form method="post">
<p style="color:var(--text-secondary,#666);font-size:14px;margin-bottom:18px;"> <p style="color:var(--text-secondary,#666);font-size:14px;margin-bottom:18px;">
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.
</p> </p>
<div class="form-group"> <div class="form-group">
<label for="totp_code">Authentifizierungs-Code</label> <label for="totp_code">Code</label>
<input type="text" id="totp_code" name="totp_code" inputmode="numeric" pattern="[0-9]*" <input type="text" id="totp_code" name="totp_code" maxlength="9"
maxlength="6" autocomplete="one-time-code" required autofocus autocomplete="one-time-code" required autofocus
style="letter-spacing:6px;text-align:center;font-size:20px;"> placeholder="123456 oder XXXX-XXXX"
style="letter-spacing:3px;text-align:center;font-size:18px;">
</div> </div>
<button type="submit" class="btn">Bestätigen</button> <button type="submit" class="btn">Bestätigen</button>
</form> </form>

View file

@ -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;