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'] ?? '')) {
|
||||
$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
|
|||
<form method="post">
|
||||
<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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -95,15 +113,33 @@ input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-ra
|
|||
<h1>🔐 Zwei-Faktor-Authentifizierung</h1>
|
||||
<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 ($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): ?>
|
||||
<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>
|
||||
<?php elseif ($totpEnabled): ?>
|
||||
<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?');">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<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'); }
|
||||
}
|
||||
|
||||
// 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';
|
|||
<i class="fas fa-key"></i>
|
||||
</a>
|
||||
<?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() ?>"
|
||||
class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"
|
||||
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,
|
||||
`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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
login.php
12
login.php
|
|
@ -169,13 +169,15 @@ try {
|
|||
<?php if ($show2fa): ?>
|
||||
<form method="post">
|
||||
<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>
|
||||
<div class="form-group">
|
||||
<label for="totp_code">Authentifizierungs-Code</label>
|
||||
<input type="text" id="totp_code" name="totp_code" inputmode="numeric" pattern="[0-9]*"
|
||||
maxlength="6" autocomplete="one-time-code" required autofocus
|
||||
style="letter-spacing:6px;text-align:center;font-size:20px;">
|
||||
<label for="totp_code">Code</label>
|
||||
<input type="text" id="totp_code" name="totp_code" maxlength="9"
|
||||
autocomplete="one-time-code" required autofocus
|
||||
placeholder="123456 oder XXXX-XXXX"
|
||||
style="letter-spacing:3px;text-align:center;font-size:18px;">
|
||||
</div>
|
||||
<button type="submit" class="btn">Bestätigen</button>
|
||||
</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