- 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>
226 lines
9.7 KiB
PHP
226 lines
9.7 KiB
PHP
<?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;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Validation\Rules;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Component;
|
|
|
|
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstellen', 'eyebrow' => 'Registrierung im Publisher-Hub', 'topRightLabel' => 'Bereits Konto?', 'topRightLinkText' => 'Anmelden', 'topRightLinkHref' => '/login'])] class extends Component {
|
|
public string $name = '';
|
|
|
|
public string $email = '';
|
|
|
|
public string $password = '';
|
|
|
|
public string $password_confirmation = '';
|
|
|
|
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(TurnstileVerifier $turnstile, ActivityLog $activityLog): void
|
|
{
|
|
$validated = $this->validate([
|
|
// 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'],
|
|
], [
|
|
'terms_accepted.accepted' => 'Bitte bestätigen Sie unsere AGB und die Datenschutzerklärung.',
|
|
]);
|
|
|
|
// Bot-/Massenanmeldungen drosseln (R6.2): pro IP, großzügig genug
|
|
// für legitime Mehrfachversuche.
|
|
if (RateLimiter::tooManyAttempts('register-ip|'.request()->ip(), 10)) {
|
|
throw ValidationException::withMessages([
|
|
'email' => __('Zu viele Registrierungen. Bitte versuchen Sie es später erneut.'),
|
|
]);
|
|
}
|
|
|
|
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']);
|
|
$validated['gdpr_consent_at'] = now();
|
|
|
|
$validated['password'] = Hash::make($validated['password']);
|
|
// Ohne expliziten Typ greift der DB-Default `existing_legacy` (für den
|
|
// Legacy-Import gedacht). Selbst-Registrierungen sind Neukunden —
|
|
// gleicher Default wie im CustomerAccountProvisioner.
|
|
$validated['registration_type'] = RegistrationType::Company->value;
|
|
// Konto bleibt bis zur E-Mail-Verifizierung inaktiv und rollenlos
|
|
// (Entscheidung 15.06.). Rolle + Aktivierung erfolgen erst nach dem
|
|
// bestätigten Verifizierungslink (ActivateUserAfterVerification).
|
|
$validated['is_active'] = false;
|
|
|
|
// Das Registered-Event versendet über den verdrahteten
|
|
// 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
|
|
// Panel ein anderes Vite-Bundle nutzt als das Hub-Auth-Layout.
|
|
$this->redirect(route('verification.notice', absolute: false));
|
|
}
|
|
}; ?>
|
|
|
|
<div>
|
|
<x-session-status class="mb-4" />
|
|
|
|
<form wire:submit="register" class="space-y-[18px]" novalidate>
|
|
|
|
<div>
|
|
<label class="field-label" for="auth-name">{{ __('Name') }}</label>
|
|
<input
|
|
id="auth-name"
|
|
type="text"
|
|
wire:model="name"
|
|
required
|
|
autofocus
|
|
autocomplete="name"
|
|
class="field-input"
|
|
placeholder="{{ __('Vor- und Nachname') }}"
|
|
@error('name') aria-invalid="true" @enderror
|
|
/>
|
|
@error('name')
|
|
<p class="field-error">{{ $message }}</p>
|
|
@enderror
|
|
</div>
|
|
|
|
<div>
|
|
<label class="field-label" for="auth-email">{{ __('E-Mail-Adresse') }}</label>
|
|
<input
|
|
id="auth-email"
|
|
type="email"
|
|
wire:model="email"
|
|
required
|
|
autocomplete="email"
|
|
class="field-input"
|
|
placeholder="redaktion@ihr-unternehmen.de"
|
|
@error('email') aria-invalid="true" @enderror
|
|
/>
|
|
@error('email')
|
|
<p class="field-error">{{ $message }}</p>
|
|
@enderror
|
|
</div>
|
|
|
|
<x-password-input
|
|
name="password"
|
|
:label="__('Passwort')"
|
|
autocomplete="new-password"
|
|
:placeholder="__('Mindestens 12 Zeichen')"
|
|
/>
|
|
|
|
<x-password-input
|
|
name="password_confirmation"
|
|
:label="__('Passwort bestätigen')"
|
|
autocomplete="new-password"
|
|
:placeholder="__('Passwort wiederholen')"
|
|
:show-error="false"
|
|
/>
|
|
|
|
<div class="!mt-5">
|
|
<label for="auth-terms" class="flex items-start gap-3 cursor-pointer select-none">
|
|
<input
|
|
id="auth-terms"
|
|
type="checkbox"
|
|
wire:model="terms_accepted"
|
|
required
|
|
class="auth-check !mt-[3px]"
|
|
@error('terms_accepted') aria-invalid="true" @enderror
|
|
/>
|
|
<span class="text-[12.5px] text-ink-2 leading-[1.55]">
|
|
{{ __('Ich habe die') }}
|
|
<a href="{{ route('agb') }}" target="_blank" rel="noopener" class="link-hub">{{ __('AGB') }}</a>
|
|
{{ __('und die') }}
|
|
<a href="{{ route('datenschutz') }}" target="_blank" rel="noopener" class="link-hub">{{ __('Datenschutzerklärung') }}</a>
|
|
{{ __('gelesen und stimme der Verarbeitung meiner Daten zur Konto-Erstellung ausdrücklich zu.') }}
|
|
</span>
|
|
</label>
|
|
@error('terms_accepted')
|
|
<p class="field-error !ml-7">{{ $message }}</p>
|
|
@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>
|
|
</button>
|
|
|
|
<div class="flex items-center gap-3 !mt-[22px] !mb-[14px]">
|
|
<span class="flex-1 h-px bg-bg-rule"></span>
|
|
<span class="text-[11px] font-semibold tracking-[0.18em] uppercase text-ink-3">{{ __('Bereits Konto?') }}</span>
|
|
<span class="flex-1 h-px bg-bg-rule"></span>
|
|
</div>
|
|
|
|
<a href="{{ route('login') }}" class="auth-btn-outline !mt-0" wire:navigate>
|
|
{{ __('Stattdessen anmelden') }}
|
|
</a>
|
|
|
|
<a href="{{ route('oauth.google.redirect') }}" class="auth-btn-outline !mt-3">
|
|
<svg width="15" height="15" viewBox="0 0 18 18" aria-hidden="true">
|
|
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.71-1.57 2.68-3.89 2.68-6.62z"/>
|
|
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
|
|
<path fill="#FBBC05" d="M3.97 10.72A5.4 5.4 0 0 1 3.68 9c0-.6.1-1.18.29-1.72V4.95H.96A9 9 0 0 0 0 9c0 1.45.35 2.82.96 4.05l3.01-2.33z"/>
|
|
<path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
|
|
</svg>
|
|
<span>{{ __('Mit Google registrieren') }}</span>
|
|
</a>
|
|
</form>
|
|
</div>
|