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:
parent
61f449f483
commit
06d2889a4d
40 changed files with 1992 additions and 40 deletions
133
resources/views/components/turnstile.blade.php
Normal file
133
resources/views/components/turnstile.blade.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
{{--
|
||||
Cloudflare Turnstile (Spamschutz B1, dev/sicherheit/ENTWICKLUNGSPLAN-
|
||||
SPAMSCHUTZ-2026-10-09.md). Serverseitig prüft TurnstileVerifier.
|
||||
|
||||
- Das Skript lädt erst, wenn das Widget sichtbar wird (Kontakt-Widget:
|
||||
beim Öffnen) — normale Seitenaufrufe sprechen Cloudflare nicht an.
|
||||
- Klassisches Formular: Turnstile legt das Feld `cf-turnstile-response`
|
||||
selbst an. Wird abgeschickt, bevor das Token da ist, wartet das Formular
|
||||
und sendet automatisch nach.
|
||||
- Livewire: `wire-token` / `wire-unavailable` nennen die Properties, die
|
||||
das Widget ohne Request setzt; `turnstile-reset` (Browser-Event) holt
|
||||
nach einer abgelehnten Prüfung ein frisches Token.
|
||||
- Lädt das Skript nicht (Cloudflare nicht erreichbar, Netz blockiert)
|
||||
oder ist das Widget falsch konfiguriert, meldet es `turnstile_unavailable`:
|
||||
Der Server lässt dann durch und markiert (Entscheidung 09.10.2026).
|
||||
Nicht bestandene Challenges (Fehlercodes 300xxx/600xxx) zählen nicht
|
||||
als Ausfall — Turnstile versucht es selbst erneut.
|
||||
--}}
|
||||
@props([
|
||||
'action',
|
||||
'wireToken' => null,
|
||||
'wireUnavailable' => null,
|
||||
])
|
||||
|
||||
@if (\App\Services\Spam\TurnstileVerifier::enabled())
|
||||
<div wire:ignore
|
||||
x-data="{
|
||||
config: @js([
|
||||
'sitekey' => (string) config('services.turnstile.site_key'),
|
||||
'action' => $action,
|
||||
'language' => app()->getLocale() === 'en' ? 'en' : 'de',
|
||||
'wireToken' => $wireToken,
|
||||
'wireUnavailable' => $wireUnavailable,
|
||||
]),
|
||||
token: '',
|
||||
unavailable: false,
|
||||
waiting: false,
|
||||
widgetId: null,
|
||||
init() {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
observer.disconnect();
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
observer.observe(this.$el);
|
||||
|
||||
const form = this.$el.closest('form');
|
||||
if (form && ! this.config.wireToken) {
|
||||
form.addEventListener('submit', (event) => {
|
||||
if (this.token || this.unavailable) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.waiting = true;
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
},
|
||||
load() {
|
||||
if (this.widgetId !== null || this.unavailable) {
|
||||
return;
|
||||
}
|
||||
window.pkTurnstileReady ??= new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('timeout')), 10000);
|
||||
window.pkTurnstileOnload = () => { clearTimeout(timer); resolve(window.turnstile); };
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=pkTurnstileOnload';
|
||||
script.async = true;
|
||||
script.onerror = () => { clearTimeout(timer); reject(new Error('load')); };
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
window.pkTurnstileReady.then((turnstile) => {
|
||||
if (this.widgetId !== null) {
|
||||
return;
|
||||
}
|
||||
this.widgetId = turnstile.render(this.$refs.box, {
|
||||
sitekey: this.config.sitekey,
|
||||
action: this.config.action,
|
||||
language: this.config.language,
|
||||
size: 'flexible',
|
||||
'refresh-expired': 'auto',
|
||||
callback: (token) => this.setToken(token),
|
||||
'expired-callback': () => this.setToken(''),
|
||||
'error-callback': (code) => {
|
||||
if (! /^(300|600)/.test(String(code))) {
|
||||
this.markUnavailable();
|
||||
}
|
||||
},
|
||||
});
|
||||
}).catch(() => {
|
||||
window.pkTurnstileReady = null;
|
||||
this.markUnavailable();
|
||||
});
|
||||
},
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
if (this.config.wireToken) {
|
||||
this.$wire.$set(this.config.wireToken, token, false);
|
||||
}
|
||||
if (token) {
|
||||
this.submitPending();
|
||||
}
|
||||
},
|
||||
markUnavailable() {
|
||||
this.unavailable = true;
|
||||
if (this.config.wireUnavailable) {
|
||||
this.$wire.$set(this.config.wireUnavailable, true, false);
|
||||
}
|
||||
this.submitPending();
|
||||
},
|
||||
submitPending() {
|
||||
if (! this.waiting) {
|
||||
return;
|
||||
}
|
||||
this.waiting = false;
|
||||
this.$nextTick(() => this.$el.closest('form')?.requestSubmit());
|
||||
},
|
||||
reset() {
|
||||
this.setToken('');
|
||||
if (this.widgetId !== null && window.turnstile) {
|
||||
window.turnstile.reset(this.widgetId);
|
||||
}
|
||||
},
|
||||
}"
|
||||
@turnstile-reset.window="reset()"
|
||||
{{ $attributes->class('min-h-[65px]') }}
|
||||
>
|
||||
<div x-ref="box"></div>
|
||||
<input type="hidden" name="{{ \App\Services\Spam\TurnstileVerifier::UNAVAILABLE_FIELD }}" :value="unavailable ? '1' : ''" />
|
||||
<p x-show="waiting" x-cloak class="mt-1 text-[11.5px] text-ink-3">{{ __('web.launcher.bot_check_waiting') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -163,6 +163,15 @@
|
|||
<a href="{{ route('melden', ['edition' => $edition]) }}" class="underline underline-offset-2 hover:text-ink">{{ __('web.launcher.legal_link') }}</a>
|
||||
</p>
|
||||
|
||||
{{-- Spamschutz für Gäste und unbestätigte Konten (siehe ContactController). --}}
|
||||
@unless (auth()->user()?->hasVerifiedEmail())
|
||||
<x-turnstile action="contact" />
|
||||
@error('turnstile')
|
||||
<p class="text-[11.5px] text-red-600 m-0">{{ $message }}</p>
|
||||
@enderror
|
||||
<input type="hidden" name="{{ \App\Services\Spam\ContactSpamFilter::STARTED_FIELD }}" value="{{ \App\Services\Spam\ContactSpamFilter::startedToken() }}" />
|
||||
@endunless
|
||||
|
||||
{{-- Bot-Schutz: versteckte Honeypot-Felder (siehe ContactController). --}}
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<label for="contact-launcher-website">Website</label>
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -207,6 +207,49 @@
|
|||
Aufbewahrungspflichten oder berechtigten Interessen an einer weiteren Speicherung
|
||||
entgegenstehen.
|
||||
</p>
|
||||
<p>
|
||||
Anfragen, die unser Spamfilter als unerwünschte Werbung oder automatisierte Eingabe
|
||||
erkennt, werden ohne Weiterleitung an unser Team zurückgehalten, damit wir Fehleinstufungen
|
||||
prüfen können, und spätestens nach 30 Tagen automatisch gelöscht.
|
||||
</p>
|
||||
|
||||
{{-- Spamschutz mit Cloudflare Turnstile (ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md), Vorschlag zur juristischen Freigabe. --}}
|
||||
<h2>6a. Schutz vor automatisierten Anfragen (Cloudflare Turnstile)</h2>
|
||||
<p>
|
||||
Um unsere Formulare vor automatisierten Eingaben (Spam, Bots) zu schützen, setzen wir im
|
||||
Kontaktformular und bei der Registrierung den Dienst Turnstile ein. Anbieter ist die
|
||||
Cloudflare, Inc., 101 Townsend St., San Francisco, CA 94107, USA.
|
||||
</p>
|
||||
<p>
|
||||
Turnstile wird erst geladen, wenn Sie das Kontaktformular öffnen oder die
|
||||
Registrierungsseite aufrufen. Der Dienst prüft anhand technischer Merkmale, ob die Eingabe
|
||||
von einem Menschen stammt; in der Regel ohne dass Sie etwas tun müssen. Dabei werden
|
||||
insbesondere verarbeitet:
|
||||
</p>
|
||||
<ul>
|
||||
<li>IP-Adresse,</li>
|
||||
<li>Informationen zu Browser und Betriebssystem (z. B. User-Agent, Spracheinstellungen),</li>
|
||||
<li>Merkmale des Seitenaufrufs und der Interaktion mit der Prüfung,</li>
|
||||
<li>Zeitpunkt der Prüfung.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Turnstile setzt nach Angaben von Cloudflare keine Cookies zu Werbe- oder Trackingzwecken
|
||||
und nutzt die Daten nicht für personalisierte Werbung. Wir erhalten von Cloudflare nur
|
||||
das Ergebnis der Prüfung. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser
|
||||
berechtigtes Interesse liegt darin, Missbrauch unserer Formulare zu verhindern, etwa den
|
||||
massenhaften Versand von Bestätigungs-E-Mails an fremde Adressen. Für den Zugriff auf
|
||||
Informationen in Ihrem Endgerät ist Rechtsgrundlage § 25 Abs. 2 Nr. 2 TDDDG, da die
|
||||
Prüfung für den sicheren Betrieb des von Ihnen genutzten Formulars unbedingt
|
||||
erforderlich ist.
|
||||
</p>
|
||||
<p>
|
||||
Cloudflare kann Daten auch in den USA verarbeiten. Cloudflare ist nach dem
|
||||
EU-US Data Privacy Framework zertifiziert; die Übermittlung erfolgt auf Grundlage des
|
||||
Angemessenheitsbeschlusses der Europäischen Kommission (Art. 45 DSGVO). Weitere
|
||||
Informationen finden Sie in den Datenschutzhinweisen von Cloudflare
|
||||
(<a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener">cloudflare.com/privacypolicy</a>)
|
||||
und zu Turnstile (<a href="https://www.cloudflare.com/turnstile-privacy-policy/" target="_blank" rel="noopener">cloudflare.com/turnstile-privacy-policy</a>).
|
||||
</p>
|
||||
|
||||
@if ($isHub)
|
||||
<h2>7. Kundenkonto und Registrierung auf dem Hub</h2>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue