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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue