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:
parent
dce10a8a08
commit
ba121d60e6
7 changed files with 179 additions and 12 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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?')">
|
||||||
|
|
|
||||||
10
database.sql
10
database.sql
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
12
login.php
12
login.php
|
|
@ -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>
|
||||||
|
|
|
||||||
16
updater/migrations/0003_maturity_features.sql
Normal file
16
updater/migrations/0003_maturity_features.sql
Normal 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;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue