Erweiterte Features (1/2): Trusted-Proxy-IP, Bandbreitenlimits, 2FA
- Schema: updater/migrations/0002 + database.sql (users.totp_*, voucher_templates qos_*, neue Tabelle api_keys) - Trusted-Proxy-IP: Auth::clientIp() wertet X-Forwarded-For nur hinter konfiguriertem trusted_proxy aus (korrektes Rate-Limit/Audit hinter Proxy) - Bandbreiten-/Datenlimits: UniFiController::createVoucher akzeptiert QoS (down/up kbit/s, Datenkontingent MB); Voucher-Profile speichern Limits, Voucher-Formular reicht sie via Template-Quick-Select durch - 2FA (TOTP, RFC 6238): includes/Totp.php (gegen RFC-Testvektoren verifiziert), zweistufiger Login, admin/security.php zum Aktivieren/Deaktivieren mit QR, Nav-Link + i18n
This commit is contained in:
parent
51485810b4
commit
eec28f77b8
12 changed files with 458 additions and 16 deletions
|
|
@ -1,4 +1,6 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/Totp.php';
|
||||
|
||||
class Auth {
|
||||
private $db;
|
||||
|
||||
|
|
@ -21,9 +23,34 @@ class Auth {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt die echte Client-IP. Hinter einem konfigurierten Trusted-Proxy
|
||||
* (Setting `trusted_proxy`, kommaseparierte IP-Liste) wird die erste IP aus
|
||||
* X-Forwarded-For verwendet, sonst REMOTE_ADDR. Verhindert, dass ein
|
||||
* Reverse-Proxy alle Clients als dieselbe IP erscheinen laesst (Rate-Limit).
|
||||
*/
|
||||
public function clientIp() {
|
||||
$remote = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
try {
|
||||
$trusted = (string)$this->db->getSetting('trusted_proxy', '');
|
||||
} catch (\Exception $e) {
|
||||
$trusted = '';
|
||||
}
|
||||
if ($trusted === '' || empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
return $remote;
|
||||
}
|
||||
$trustedList = array_filter(array_map('trim', explode(',', $trusted)));
|
||||
if (!in_array($remote, $trustedList, true)) {
|
||||
return $remote; // Anfrage kam nicht vom Trusted-Proxy -> XFF ignorieren
|
||||
}
|
||||
$parts = array_filter(array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])));
|
||||
$client = $parts[0] ?? $remote;
|
||||
return filter_var($client, FILTER_VALIDATE_IP) ? $client : $remote;
|
||||
}
|
||||
|
||||
// Benutzer einloggen
|
||||
public function login($email, $password) {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$ip = $this->clientIp();
|
||||
|
||||
if ($this->isRateLimited($ip, $email)) {
|
||||
return 'rate_limited';
|
||||
|
|
@ -36,6 +63,14 @@ class Auth {
|
|||
|
||||
if ($user && password_verify($password, $user['password_hash'])) {
|
||||
$this->clearLoginAttempts($ip, $email);
|
||||
|
||||
// 2FA aktiv? Dann Login zunaechst nur "vormerken" und Code anfordern.
|
||||
if (!empty($user['totp_enabled']) && !empty($user['totp_secret'])) {
|
||||
$_SESSION['totp_pending_user_id'] = $user['id'];
|
||||
$_SESSION['totp_pending_time'] = time();
|
||||
return 'totp_required';
|
||||
}
|
||||
|
||||
$this->setUserSession($user);
|
||||
$this->updateLastLogin($user['id']);
|
||||
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich');
|
||||
|
|
@ -46,11 +81,54 @@ class Auth {
|
|||
return false;
|
||||
}
|
||||
|
||||
/** Liegt ein Login vor, der noch auf den 2FA-Code wartet? */
|
||||
public function isTotpPending() {
|
||||
return isset($_SESSION['totp_pending_user_id'])
|
||||
&& isset($_SESSION['totp_pending_time'])
|
||||
&& (time() - (int)$_SESSION['totp_pending_time']) < 300; // 5 Min Fenster
|
||||
}
|
||||
|
||||
/** Schliesst einen 2FA-Login mit dem eingegebenen Code ab. */
|
||||
public function verifyTotpLogin($code) {
|
||||
if (!$this->isTotpPending()) {
|
||||
return false;
|
||||
}
|
||||
$user = $this->db->fetchOne(
|
||||
"SELECT * FROM users WHERE id = ? AND is_active = 1",
|
||||
[$_SESSION['totp_pending_user_id']]
|
||||
);
|
||||
if (!$user || empty($user['totp_secret'])) {
|
||||
unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_time']);
|
||||
return false;
|
||||
}
|
||||
if (!Totp::verify($user['totp_secret'], $code)) {
|
||||
$this->writeAuditLog($user['id'], 'user_login_2fa_failed', 'user', $user['id'], '2FA-Code falsch');
|
||||
return false;
|
||||
}
|
||||
unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_time']);
|
||||
$this->setUserSession($user);
|
||||
$this->updateLastLogin($user['id']);
|
||||
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich (2FA)');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 2FA fuer einen Benutzer aktivieren (nach erfolgreicher Code-Verifikation). */
|
||||
public function enableTotp($userId, $secret) {
|
||||
$this->db->query("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE id = ?", [$secret, $userId]);
|
||||
$this->writeAuditLog($userId, 'totp_enabled', 'user', $userId, '2FA aktiviert');
|
||||
}
|
||||
|
||||
/** 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->writeAuditLog($userId, 'totp_disabled', 'user', $userId, '2FA deaktiviert');
|
||||
}
|
||||
|
||||
public function writeAuditLog($userId, $action, $entityType = null, $entityId = null, $details = null) {
|
||||
try {
|
||||
$this->db->execute(
|
||||
"INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[$userId, $action, $entityType, $entityId !== null ? (string)$entityId : null, $details, $_SERVER['REMOTE_ADDR'] ?? '']
|
||||
[$userId, $action, $entityType, $entityId !== null ? (string)$entityId : null, $details, $this->clientIp()]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// audit_log table may not exist on old installs
|
||||
|
|
|
|||
83
includes/Totp.php
Normal file
83
includes/Totp.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
/**
|
||||
* Totp – minimaler TOTP-Generator/-Validator nach RFC 6238 (HMAC-SHA1,
|
||||
* 6 Stellen, 30s-Zeitfenster). Reine PHP-Standardlib, keine Abhaengigkeiten.
|
||||
* Kompatibel mit Google Authenticator, Authy, Microsoft Authenticator etc.
|
||||
*/
|
||||
class Totp {
|
||||
private const DIGITS = 6;
|
||||
private const PERIOD = 30;
|
||||
private const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
/** Erzeugt ein neues Base32-Secret (Standard 16 Zeichen = 80 bit). */
|
||||
public static function generateSecret($length = 16) {
|
||||
$secret = '';
|
||||
$bytes = random_bytes($length);
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$secret .= self::BASE32[ord($bytes[$i]) & 31];
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/** Aktueller Code fuer ein Secret. */
|
||||
public static function code($secret, $timeSlice = null) {
|
||||
if ($timeSlice === null) {
|
||||
$timeSlice = (int) floor(time() / self::PERIOD);
|
||||
}
|
||||
$key = self::base32Decode($secret);
|
||||
// 8-Byte Big-Endian Counter
|
||||
$binTime = pack('N*', 0) . pack('N*', $timeSlice);
|
||||
$hash = hash_hmac('sha1', $binTime, $key, true);
|
||||
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
|
||||
$part = substr($hash, $offset, 4);
|
||||
$value = unpack('N', $part)[1] & 0x7FFFFFFF;
|
||||
$mod = $value % (10 ** self::DIGITS);
|
||||
return str_pad((string)$mod, self::DIGITS, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft einen Code mit Toleranzfenster (+/- $window Zeitschritte gegen
|
||||
* Uhren-Drift). Konstante-Zeit-Vergleich gegen Timing-Angriffe.
|
||||
*/
|
||||
public static function verify($secret, $code, $window = 1) {
|
||||
if (!preg_match('/^\d{6}$/', (string)$code)) {
|
||||
return false;
|
||||
}
|
||||
$current = (int) floor(time() / self::PERIOD);
|
||||
for ($i = -$window; $i <= $window; $i++) {
|
||||
if (hash_equals(self::code($secret, $current + $i), (string)$code)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** otpauth://-URI fuer QR-Code-Provisionierung. */
|
||||
public static function provisioningUri($secret, $accountName, $issuer) {
|
||||
$label = rawurlencode($issuer . ':' . $accountName);
|
||||
$params = http_build_query([
|
||||
'secret' => $secret,
|
||||
'issuer' => $issuer,
|
||||
'algorithm' => 'SHA1',
|
||||
'digits' => self::DIGITS,
|
||||
'period' => self::PERIOD,
|
||||
]);
|
||||
return "otpauth://totp/$label?$params";
|
||||
}
|
||||
|
||||
private static function base32Decode($b32) {
|
||||
$b32 = strtoupper(rtrim($b32, '='));
|
||||
$buffer = 0; $bitsLeft = 0; $out = '';
|
||||
for ($i = 0; $i < strlen($b32); $i++) {
|
||||
$val = strpos(self::BASE32, $b32[$i]);
|
||||
if ($val === false) continue;
|
||||
$buffer = ($buffer << 5) | $val;
|
||||
$bitsLeft += 5;
|
||||
if ($bitsLeft >= 8) {
|
||||
$bitsLeft -= 8;
|
||||
$out .= chr(($buffer >> $bitsLeft) & 0xFF);
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
|
@ -156,7 +156,8 @@ class UniFiController {
|
|||
}
|
||||
|
||||
// Voucher erstellen
|
||||
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480) {
|
||||
// $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
|
||||
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) {
|
||||
$data = [
|
||||
'cmd' => 'create-voucher',
|
||||
'expire' => (int)$expireMinutes,
|
||||
|
|
@ -164,7 +165,18 @@ class UniFiController {
|
|||
'note' => $voucherName,
|
||||
'quota' => (int)$maxUses
|
||||
];
|
||||
|
||||
|
||||
// Bandbreiten-/Datenlimits (UniFi QoS) optional setzen
|
||||
$down = isset($options['down']) ? (int)$options['down'] : 0;
|
||||
$up = isset($options['up']) ? (int)$options['up'] : 0;
|
||||
$bytes = isset($options['quota_mb']) ? (int)$options['quota_mb'] : 0;
|
||||
if ($down > 0 || $up > 0 || $bytes > 0) {
|
||||
$data['qos_overwrite'] = true;
|
||||
if ($down > 0) $data['down'] = $down; // kbit/s
|
||||
if ($up > 0) $data['up'] = $up; // kbit/s
|
||||
if ($bytes > 0) $data['bytes'] = $bytes; // Megabyte
|
||||
}
|
||||
|
||||
$response = $this->apiRequest("/proxy/network/api/s/{$this->siteId}/cmd/hotspot", $data);
|
||||
|
||||
if (!isset($response['data'][0]['create_time'])) {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,9 @@ $lang = I18n::getLanguage();
|
|||
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
|
||||
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>security.php" class="<?= $currentPage === 'security' ? 'active' : '' ?>">
|
||||
<i class="fas fa-user-shield"></i> <?= __('nav_security') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>update.php" class="<?= $currentPage === 'update' ? 'active' : '' ?>">
|
||||
<i class="fas fa-sync-alt"></i> <?= __('nav_update') ?>
|
||||
</a></li>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue