Spamschutz Kontakt + Registrierung: Turnstile, Rate-Limit, Honeypot; Passwort min. 8

- Kontaktformular: keine Mail mehr an die eingegebene Adresse (wurde als
  Spam-Versender missbraucht); Anfrage nur an Kontakt-Adresse + Berater (BCC),
  Reply-To = Absender; Eingaben in der Mail escaped
- Kontakt + Registrierung: Cloudflare Turnstile statt reCAPTCHA (Schluessel
  aus dem Code in die .env), Rate-Limits, Honeypot + Mindest-Ausfuellzeit,
  Namensregel (lateinische Schrift, max. 50 Zeichen), Form Requests
- Registrierung: Partner-Nummer nur fuer aktive Berater (vorher ungeprueft)
- CRM: POST /register abgeschaltet (legte ohne Captcha Benutzer an)
- Passwort-Mindestlaenge systemweit 8 Zeichen ueber Password::defaults()
- Log abgefangener Anfragen: storage/logs/form-spam-*.log
- Doku und Protokoll: dev/2026-10-09-spam/README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-09 08:22:02 +00:00
parent fe1a9478f4
commit 4c944aa15d
44 changed files with 1320 additions and 248 deletions

View file

@ -0,0 +1,84 @@
<?php
namespace App\Http\Requests\Contact;
use App\Rules\PersonName;
use App\Rules\TurnstileToken;
use App\Services\FormSpamGuard;
use App\Services\Util;
use Closure;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
class ContactStoreRequest extends FormRequest
{
public const PHONE_PATTERN = '/^[0-9 +\-\/().]+$/';
public const MAX_LINKS = 2;
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
$rules = [
'salutation' => ['required', 'in:mr,ms'],
'first_name' => ['required', 'string', new PersonName],
'last_name' => ['required', 'string', new PersonName],
'email' => ['required', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:30', 'regex:'.self::PHONE_PATTERN],
'message' => ['required', 'string', 'max:3000', $this->maxLinksRule()],
'accepted_data_protection' => ['accepted'],
'cf-turnstile-response' => ['required', new TurnstileToken($this->ip())],
];
if ($this->asksForSalesPartnership()) {
$rules['sales_partnership'] = ['required', 'in:JA,NEIN'];
$rules['sales_partnership_message'] = ['required_if:sales_partnership,JA', 'nullable', 'string', 'max:3000', $this->maxLinksRule()];
}
return $rules;
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'phone.regex' => __('contact.phone_invalid'),
'cf-turnstile-response.required' => __('form_protection.captcha_failed'),
];
}
/**
* Abfrage zur Vertriebspartnerschaft nur auf der Hauptseite und im Firmen-Shop.
*/
public function asksForSalesPartnership(): bool
{
$userShop = Util::getUserShop();
return ! $userShop || $userShop->id === 22;
}
protected function failedValidation(Validator $validator): void
{
FormSpamGuard::log($this, 'contact', 'validation', ['fields' => array_keys($validator->failed())]);
parent::failedValidation($validator);
}
private function maxLinksRule(): Closure
{
return function (string $attribute, mixed $value, Closure $fail): void {
if (preg_match_all('~https?://|www\.~i', (string) $value) > self::MAX_LINKS) {
$fail(__('contact.too_many_links'));
}
};
}
}