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

@ -19,13 +19,41 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
#[Url(as: 'topic', except: 'all')]
public string $topicFilter = 'all';
/** @var list<string> Auswahl für die Sammelaktion „Als Spam markieren“. */
public array $selected = [];
public string $notification = '';
public function updatedStatusFilter(): void
{
$this->selected = [];
$this->resetPage();
}
/**
* Sammelaktion: ausgewählte Anliegen in die Spam-Quarantäne legen
* (Spamschutz B3). Keine Mail, Löschung nach 30 Tagen.
*/
public function markSelectedAsSpam(): void
{
$this->authorize('compliance:manage');
$messages = ContactMessage::query()
->whereKey(array_map('intval', $this->selected))
->where('status', '!=', ContactMessageStatus::Spam->value)
->get();
foreach ($messages as $message) {
$message->markAsSpam(auth()->user());
}
$this->selected = [];
$this->notification = __(':count Anliegen als Spam markiert.', ['count' => $messages->count()]);
}
public function updatedTopicFilter(): void
{
$this->selected = [];
$this->resetPage();
}
@ -42,7 +70,8 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
'messages' => $messages,
'topicOptions' => ContactMessageTopic::cases(),
'openCount' => ContactMessage::query()->open()->count(),
'totalCount' => ContactMessage::query()->count(),
'spamCount' => ContactMessage::query()->where('status', ContactMessageStatus::Spam->value)->count(),
'totalCount' => ContactMessage::query()->where('status', '!=', ContactMessageStatus::Spam->value)->count(),
];
}
@ -53,6 +82,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
ContactMessageStatus::InProgress => 'hub',
ContactMessageStatus::Resolved => 'ok',
ContactMessageStatus::Rejected => 'err',
ContactMessageStatus::Spam => 'muted',
};
}
}; ?>
@ -67,14 +97,18 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
/>
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<x-portal.stat-card variant="primary" :label="__('Offen')" :value="number_format($openCount)">
<x-slot:meta>{{ __('zu bearbeiten') }}</x-slot:meta>
<x-slot:trend>{{ __('offen + in Bearbeitung') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Gesamt')" :value="number_format($totalCount)">
<x-slot:meta>{{ __('alle Anliegen') }}</x-slot:meta>
<x-slot:trend>{{ __('inkl. erledigt') }}</x-slot:trend>
<x-slot:trend>{{ __('inkl. erledigt, ohne Spam') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Spam')" :value="number_format($spamCount)">
<x-slot:meta>{{ __('in Quarantäne') }}</x-slot:meta>
<x-slot:trend>{{ __('ohne Mail, nach :days Tagen gelöscht', ['days' => \App\Models\ContactMessage::SPAM_RETENTION_DAYS]) }}</x-slot:trend>
</x-portal.stat-card>
</section>
@ -89,6 +123,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
<option value="all">{{ __('Alle Status') }}</option>
<option value="resolved">{{ __('Erledigt') }}</option>
<option value="rejected">{{ __('Abgelehnt') }}</option>
<option value="spam">{{ __('Spam (Quarantäne)') }}</option>
</flux:select>
<flux:select wire:model.live="topicFilter" class="w-full sm:w-56">
@ -104,12 +139,28 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Anliegen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $messages->total()]) }}
</span>
<div class="flex items-center gap-3">
@if (count($selected) > 0)
<flux:button size="sm" variant="danger" icon="no-symbol" wire:click="markSelectedAsSpam"
wire:confirm="{{ __('Ausgewählte Anliegen als Spam markieren? Es geht keine Mail raus, nach 30 Tagen werden sie gelöscht.') }}">
{{ __(':count als Spam markieren', ['count' => count($selected)]) }}
</flux:button>
@endif
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $messages->total()]) }}
</span>
</div>
</div>
@if ($notification)
<div class="mx-5 mt-4 px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
{{ $notification }}
</div>
@endif
<flux:table>
<flux:table.columns>
<flux:table.column class="w-8"><span class="sr-only">{{ __('Auswahl') }}</span></flux:table.column>
<flux:table.column>{{ __('Anliegen') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Absender') }}</flux:table.column>
@ -121,6 +172,13 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
<flux:table.rows>
@forelse ($messages as $message)
<flux:table.row :key="$message->id">
<flux:table.cell>
@unless ($message->isSpam())
<input type="checkbox" wire:model.live="selected" value="{{ $message->id }}"
aria-label="{{ __('Anliegen :ref auswählen', ['ref' => $message->reference()]) }}"
class="size-4 cursor-pointer accent-[color:var(--color-hub)]" />
@endunless
</flux:table.cell>
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $message->topic->label() }}
@ -133,6 +191,11 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
<span class="badge dot {{ $this->statusBadgeClass($message->status) }}">
{{ $message->status->label() }}
</span>
@if ($message->isSpam() && $message->spamReasonLabel())
<span class="block mt-1 text-[11px] text-[color:var(--color-ink-3)]">{{ $message->spamReasonLabel() }}</span>
@elseif ($message->skippedBotCheck())
<span class="badge warn mt-1">{{ __('ohne Bot-Prüfung') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink)]">
@ -161,7 +224,7 @@ new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Componen
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="6">
<flux:table.cell colspan="7">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">

View file

@ -2,6 +2,7 @@
use App\Enums\ContactMessageStatus;
use App\Models\ContactMessage;
use App\Services\Web\ContactMessageNotifier;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
@ -56,6 +57,34 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
$this->notification = __('Anliegen wieder geöffnet.');
}
public function markAsSpam(): void
{
$this->authorize('compliance:manage');
$this->message()->markAsSpam(auth()->user());
$this->notification = __('Als Spam markiert. Nach :days Tagen wird das Anliegen gelöscht.', ['days' => ContactMessage::SPAM_RETENTION_DAYS]);
}
/**
* Fehlalarm der Spam-Quarantäne: wieder öffnen und die Team-Mail
* nachholen, die beim Eingang zurückgehalten wurde.
*/
public function releaseFromSpam(ContactMessageNotifier $notifier): void
{
$this->authorize('compliance:manage');
$message = $this->message();
if ($message->releaseFromSpam()) {
$notifier->notify($message->refresh());
$this->notification = __('Kein Spam: Anliegen wieder geöffnet, die Mail an das Team wurde nachgeholt.');
return;
}
$this->notification = __('Kein Spam: Anliegen wieder geöffnet.');
}
private function finish(ContactMessageStatus $status, string $notification): void
{
$this->message()->update([
@ -85,6 +114,7 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
ContactMessageStatus::InProgress => 'hub',
ContactMessageStatus::Resolved => 'ok',
ContactMessageStatus::Rejected => 'err',
ContactMessageStatus::Spam => 'muted',
};
}
}; ?>
@ -155,6 +185,18 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Portal') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $message->portal ?: '—' }}</dd>
</div>
@if ($message->spamReasonLabel())
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Spamschutz') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $message->spamReasonLabel() }}</dd>
</div>
@endif
@if ($message->skippedBotCheck())
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Bot-Prüfung') }}</dt>
<dd><span class="badge warn">{{ __('Cloudflare nicht erreichbar — bitte prüfen') }}</span></dd>
</div>
@endif
@if ($message->user)
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Benutzerkonto') }}</dt>
@ -217,6 +259,13 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
<flux:button variant="danger" icon="x-mark" wire:click="reject">
{{ __('Ablehnen') }}
</flux:button>
<flux:button variant="ghost" icon="no-symbol" wire:click="markAsSpam">
{{ __('Als Spam markieren') }}
</flux:button>
@elseif ($message->isSpam())
<flux:button variant="primary" icon="arrow-uturn-left" wire:click="releaseFromSpam">
{{ __('Kein Spam — wieder öffnen') }}
</flux:button>
@else
<flux:button variant="filled" icon="arrow-path" wire:click="reopen">
{{ __('Wieder öffnen') }}

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>