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

@ -11,6 +11,7 @@ use App\Console\Commands\ExpireGrandfathering;
use App\Console\Commands\FetchMailBounces;
use App\Console\Commands\GenerateManualInvoices;
use App\Console\Commands\MonitoringHeartbeat;
use App\Console\Commands\PruneBotRegistrations;
use App\Console\Commands\PublishScheduledPressReleases;
use App\Console\Commands\PurgeConversionEvents;
use App\Console\Commands\PurgeExpiredPressReleaseDrafts;
@ -20,6 +21,7 @@ use App\Console\Commands\PurgeRedactionSnapshots;
use App\Console\Commands\RefreshMarketData;
use App\Console\Commands\ResetMonthlyPressReleaseQuota;
use App\Console\Commands\SweepOrphanPressReleaseOriginals;
use App\Models\ContactMessage;
use App\Models\FailedLoginTally;
use App\Support\OperationalAlert;
use Illuminate\Console\Scheduling\Event;
@ -70,6 +72,16 @@ Schedule::command(CheckMailDomains::class, ['--stale', '--limit=500'])
->runInBackground()
->onFailure($alertOnFailure('mail:check-domains'));
// Bot-Registrierungen löschen (Spamschutz B4, täglich 03:45): unbestätigt,
// nie genutzt, älter als 14 Tage. Aus, bis der Probelauf auf Live geprüft
// ist (USERS_PRUNE_BOT_REGISTRATIONS=true).
Schedule::command(PruneBotRegistrations::class)
->dailyAt('03:45')
->when(fn (): bool => (bool) config('auth.prune_bot_registrations.enabled'))
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('users:prune-bot-registrations'));
// Magic-Links: Abgelaufene / verbrauchte Tokens entfernen (täglich 03:00)
Schedule::command(PurgeMagicLinks::class, ['--days=30'])
->dailyAt('03:00')
@ -247,12 +259,13 @@ Schedule::command(MonitoringHeartbeat::class)
->onFailure($alertOnFailure('monitoring:heartbeat'));
// Tageszähler fehlgeschlagener Anmeldungen nach der Löschfrist entfernen
// (täglich 04:05, config monitoring.failed_logins.retention_days).
Schedule::command('model:prune', ['--model' => [FailedLoginTally::class]])
// (täglich 04:05, config monitoring.failed_logins.retention_days) und
// Spam-Quarantäne des Kontaktformulars nach 30 Tagen (Spamschutz B3).
Schedule::command('model:prune', ['--model' => [FailedLoginTally::class, ContactMessage::class]])
->dailyAt('04:05')
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('model:prune failed_login_tallies'));
->onFailure($alertOnFailure('model:prune failed_login_tallies, contact_messages'));
// Fehlgeschlagene Jobs nach 30 Tagen entfernen (täglich 03:55). Bis dahin
// sind sie im Systemstatus sichtbar und per `queue:retry` wiederholbar.