feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile

- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier,
  <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und
  im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert
- Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit
  Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden);
  Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen
- Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach
- Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations
  (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true)
- contact-messages:quarantine-spam für den Altbestand
- CSP erlaubt challenges.cloudflare.com, Log-Kanal spam
- Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-09 08:49:58 +00:00
parent 61f449f483
commit 06d2889a4d
40 changed files with 1992 additions and 40 deletions

View file

@ -1,7 +1,11 @@
<?php
use App\Enums\ActivityType;
use App\Enums\RegistrationType;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Spam\GibberishDetector;
use App\Services\Spam\TurnstileVerifier;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@ -22,13 +26,24 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
public bool $terms_accepted = false;
/** Vom Turnstile-Widget gesetzt (Spamschutz B4). */
public string $turnstileToken = '';
public bool $turnstileUnavailable = false;
/**
* Handle an incoming registration request.
*/
public function register(): void
public function register(TurnstileVerifier $turnstile, ActivityLog $activityLog): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
// Bots registrieren mit Zufallsnamen („ruLrlotwaCoVStmZetSxD“),
// 389 Konten in 30 Tagen bis 07.10.2026 (Spamschutz B4).
'name' => ['required', 'string', 'max:255', function (string $attribute, mixed $value, \Closure $fail): void {
if (is_string($value) && GibberishDetector::isRandomPersonName($value)) {
$fail(__('Bitte geben Sie Ihren Vor- und Nachnamen an.'));
}
}],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
'terms_accepted' => ['accepted'],
@ -46,6 +61,21 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
RateLimiter::hit('register-ip|'.request()->ip(), 3600);
// Jede Registrierung verschickt eine Bestätigungsmail an die
// angegebene Adresse — ohne Bot-Prüfung ein Werkzeug, fremde
// Postfächer zu fluten. Ist Cloudflare nicht erreichbar, wird
// durchgelassen und im Verlauf markiert (Entscheidung 09.10.2026).
$botCheck = $turnstile->verify($this->turnstileToken, $this->turnstileUnavailable, 'register', request()->ip());
if ($botCheck->blocks()) {
$this->turnstileToken = '';
$this->dispatch('turnstile-reset');
throw ValidationException::withMessages([
'turnstile' => __('Die Sicherheitsprüfung ist fehlgeschlagen. Bitte warten Sie einen Moment und versuchen Sie es erneut.'),
]);
}
// Die bestätigte AGB-/Datenschutz-Checkbox wird als DSGVO-Einwilligung
// mit Zeitstempel festgehalten (users.gdpr_consent_at).
unset($validated['terms_accepted']);
@ -65,6 +95,16 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
// SendEmailVerificationNotification-Listener die Bestätigungsmail.
event(new Registered($user = User::create($validated)));
if ($botCheck->needsReview()) {
$activityLog->record(
ActivityType::BotCheckUnavailable,
sprintf('Registrierung von %s ohne Bot-Prüfung angenommen (%s) — bitte nachprüfen.', $user->email, $botCheck->reason),
user: $user,
subject: $user,
meta: ['form' => 'register', 'reason' => $botCheck->reason],
);
}
Auth::login($user);
// Direkt zur Bestätigungs-/Notice-Seite. Ohne navigate:true, weil das
@ -151,6 +191,13 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
@enderror
</div>
<div>
<x-turnstile action="register" wire-token="turnstileToken" wire-unavailable="turnstileUnavailable" />
@error('turnstile')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="auth-btn-primary !mt-[18px]" wire:loading.attr="disabled" wire:target="register">
<span wire:loading.remove wire:target="register">{{ __('Konto erstellen') }}</span>
<span wire:loading wire:target="register">{{ __('Konto wird angelegt …') }}</span>