CAPTCHA für öffentlichen Modus (Rechenaufgabe oder hCaptcha)

- includes/Captcha.php (math self-contained / hcaptcha via siteverify)
- index.php: Verifikation bei anonymer Erstellung + Anzeige in beiden Formularen
- Settings in admin/integrations.php (Modus, Site-Key, Secret)
This commit is contained in:
Claude 2026-06-06 05:42:06 +00:00
parent 546accc185
commit ce75215498
No known key found for this signature in database
3 changed files with 95 additions and 0 deletions

View file

@ -24,6 +24,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
$error = __('error_csrf'); $error = __('error_csrf');
} else { } else {
$db->setSetting('enforce_2fa_admins', isset($_POST['enforce_2fa_admins']) ? '1' : '0'); $db->setSetting('enforce_2fa_admins', isset($_POST['enforce_2fa_admins']) ? '1' : '0');
$cm = in_array($_POST['captcha_mode'] ?? 'off', ['off','math','hcaptcha'], true) ? $_POST['captcha_mode'] : 'off';
$db->setSetting('captcha_mode', $cm);
$db->setSetting('captcha_site_key', trim($_POST['captcha_site_key'] ?? ''));
if (!empty($_POST['captcha_secret'])) { $db->setSetting('captcha_secret', trim($_POST['captcha_secret'])); }
$db->setSetting('user_daily_voucher_limit', max(0, (int)($_POST['user_daily_voucher_limit'] ?? 0))); $db->setSetting('user_daily_voucher_limit', max(0, (int)($_POST['user_daily_voucher_limit'] ?? 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');
@ -42,6 +46,9 @@ if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrf
} }
$enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1'; $enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
$captchaMode = $db->getSetting('captcha_mode', 'off');
$captchaSiteKey = $db->getSetting('captcha_site_key', '');
$captchaSecretSet = $db->getSetting('captcha_secret', '') !== '';
$dailyLimit = (int)$db->getSetting('user_daily_voucher_limit', 0); $dailyLimit = (int)$db->getSetting('user_daily_voucher_limit', 0);
$trustedProxy = $db->getSetting('trusted_proxy', ''); $trustedProxy = $db->getSetting('trusted_proxy', '');
$webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1'; $webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1';
@ -90,6 +97,16 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label> <label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label>
<label>Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)</label> <label>Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)</label>
<input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;"> <input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;">
<label>Captcha im öffentlichen Modus</label>
<select class="input" name="captcha_mode" style="max-width:240px;">
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>>Aus</option>
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>>Rechenaufgabe (ohne externen Dienst)</option>
<option value="hcaptcha" <?= $captchaMode==='hcaptcha'?'selected':'' ?>>hCaptcha</option>
</select>
<div class="row3" style="margin-top:10px;">
<div><label>hCaptcha Site-Key</label><input class="input" type="text" name="captcha_site_key" value="<?= htmlspecialchars($captchaSiteKey) ?>"></div>
<div><label>hCaptcha Secret<?= $captchaSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
</div>
</div> </div>
<div class="card"> <div class="card">

56
includes/Captcha.php Normal file
View file

@ -0,0 +1,56 @@
<?php
/**
* Captcha Schutz der öffentlichen (anonymen) Voucher-Erstellung.
*
* Modi (Setting captcha_mode):
* 'off' deaktiviert
* 'math' selbst-enthaltenes Rechen-Captcha (keine externen Dienste)
* 'hcaptcha' hCaptcha (Setting captcha_site_key / captcha_secret)
*
* Reine PHP-Standardlib + cURL (für hCaptcha-Verifizierung).
*/
class Captcha {
public static function mode($db) {
$m = (string)$db->getSetting('captcha_mode', 'off');
return in_array($m, ['off', 'math', 'hcaptcha'], true) ? $m : 'off';
}
/** Frage für das Math-Captcha erzeugen und Antwort in Session hinterlegen. */
public static function newMathChallenge() {
$a = random_int(1, 9);
$b = random_int(1, 9);
$_SESSION['captcha_answer'] = (string)($a + $b);
return "$a + $b";
}
/** Prüft die Captcha-Antwort des aktuellen Requests. */
public static function verify($db) {
$mode = self::mode($db);
if ($mode === 'off') {
return true;
}
if ($mode === 'math') {
$expected = $_SESSION['captcha_answer'] ?? null;
unset($_SESSION['captcha_answer']); // einmalig
$given = trim((string)($_POST['captcha'] ?? ''));
return $expected !== null && hash_equals((string)$expected, $given);
}
if ($mode === 'hcaptcha') {
$resp = $_POST['h-captcha-response'] ?? '';
if ($resp === '') return false;
$secret = (string)$db->getSetting('captcha_secret', '');
$ch = curl_init('https://hcaptcha.com/siteverify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(['secret' => $secret, 'response' => $resp]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
]);
$out = curl_exec($ch);
curl_close($ch);
$data = json_decode((string)$out, true);
return is_array($data) && !empty($data['success']);
}
return true;
}
}

View file

@ -17,6 +17,7 @@ require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/UniFiController.php'; require_once __DIR__ . '/includes/UniFiController.php';
require_once __DIR__ . '/includes/Mailer.php'; require_once __DIR__ . '/includes/Mailer.php';
require_once __DIR__ . '/includes/Notifier.php'; require_once __DIR__ . '/includes/Notifier.php';
require_once __DIR__ . '/includes/Captcha.php';
require_once __DIR__ . '/includes/I18n.php'; require_once __DIR__ . '/includes/I18n.php';
$auth = new Auth(); $auth = new Auth();
@ -151,6 +152,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
$error = __('error_csrf'); $error = __('error_csrf');
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) { } elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) {
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.'; $error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.';
} elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) {
$error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.';
} else { } else {
try { try {
$siteId = (int)($_POST['site_id'] ?? 0); $siteId = (int)($_POST['site_id'] ?? 0);
@ -203,6 +206,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
$error = __('error_csrf'); $error = __('error_csrf');
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) { } elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) {
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.'; $error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.';
} elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) {
$error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.';
} else { } else {
try { try {
$siteId = (int)($_POST['site_id'] ?? 0); $siteId = (int)($_POST['site_id'] ?? 0);
@ -242,6 +247,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
$currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null; $currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null;
// Captcha nur für anonyme öffentliche Erstellung
$captchaMode = !$auth->isLoggedIn() ? Captcha::mode($db) : 'off';
$captchaQuestion = $captchaMode === 'math' ? Captcha::newMathChallenge() : '';
$hcaptchaSiteKey = $captchaMode === 'hcaptcha' ? $db->getSetting('captcha_site_key', '') : '';
$captchaHtml = '';
if ($captchaMode === 'math') {
$captchaHtml = '<div class="form-group"><label>Sicherheitsfrage: Wie viel ist ' . htmlspecialchars($captchaQuestion) . '?</label>'
. '<input type="text" name="captcha" inputmode="numeric" required></div>';
} elseif ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== '') {
$captchaHtml = '<div class="form-group"><div class="h-captcha" data-sitekey="' . htmlspecialchars($hcaptchaSiteKey) . '"></div></div>';
}
// Build print HTML for each voucher // Build print HTML for each voucher
function buildPrintCard($template, $data, $instructionHeader, $instructionText, $appTitle) { function buildPrintCard($template, $data, $instructionHeader, $instructionText, $appTitle) {
$instructions = $instructionHeader || $instructionText $instructions = $instructionHeader || $instructionText
@ -261,6 +278,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($appTitle) ?></title> <title><?= htmlspecialchars($appTitle) ?></title>
<link rel="stylesheet" href="assets/global.css"> <link rel="stylesheet" href="assets/global.css">
<?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?>
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<?php endif; ?>
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script> <script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<?php if ($voucherCreated): ?> <?php if ($voucherCreated): ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
@ -510,6 +530,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<input type="hidden" name="qos_down" class="qos-down-field" value="0"> <input type="hidden" name="qos_down" class="qos-down-field" value="0">
<input type="hidden" name="qos_up" class="qos-up-field" value="0"> <input type="hidden" name="qos_up" class="qos-up-field" value="0">
<input type="hidden" name="qos_quota" class="qos-quota-field" value="0"> <input type="hidden" name="qos_quota" class="qos-quota-field" value="0">
<?= $captchaHtml ?>
<div class="form-group"> <div class="form-group">
<label for="voucher_name"><?= __('voucher_name_label') ?></label> <label for="voucher_name"><?= __('voucher_name_label') ?></label>
@ -570,6 +591,7 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<input type="hidden" name="qos_down" class="qos-down-field" value="0"> <input type="hidden" name="qos_down" class="qos-down-field" value="0">
<input type="hidden" name="qos_up" class="qos-up-field" value="0"> <input type="hidden" name="qos_up" class="qos-up-field" value="0">
<input type="hidden" name="qos_quota" class="qos-quota-field" value="0"> <input type="hidden" name="qos_quota" class="qos-quota-field" value="0">
<?= $captchaHtml ?>
<div class="form-group"> <div class="form-group">
<label for="bulk_count"><?= __('bulk_quantity') ?></label> <label for="bulk_count"><?= __('bulk_quantity') ?></label>