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

@ -8,6 +8,7 @@ use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
@ -58,7 +59,7 @@ class RegisterController extends Controller
return Validator::make($data, [
'name' => 'required|string|max:255',
'email' => 'required|string|email:filter|max:255|unique:users',
'password' => 'required|string|min:6|confirmed',
'password' => ['required', 'string', 'confirmed', Password::defaults()],
]);
}

View file

@ -1,39 +1,38 @@
<?php
namespace App\Http\Controllers;
use Auth;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Hash;
use Validator;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
use Request;
use Validator;
class UserUpdatePasswordController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
/**
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @return Factory|View
*/
public function updatePassword()
{
return view('user.update_password');
}
public function updatePasswordStore()
{
$rules = array(
'old_password' => 'required|old_password:' . Auth::user()->password,
'password' => 'required|string|min:8|confirmed',
);
$rules = [
'old_password' => 'required|old_password:'.Auth::user()->password,
'password' => ['required', 'string', 'confirmed', Password::defaults()],
];
Validator::extend('old_password', function ($attribute, $value, $parameters, $validator) {
@ -47,6 +46,7 @@ class UserUpdatePasswordController extends Controller
// get the error messages from the validator
$messages = $validator->messages();
// redirect our user back to the form with the errors from the validator
return view('user.update_password')->withErrors($validator);
@ -54,33 +54,32 @@ class UserUpdatePasswordController extends Controller
$user = Auth::user();
$data = Request::all();
$user->fill([
'password' => Hash::make($data['password'])
'password' => Hash::make($data['password']),
])->save();
\Session()->flash('alert-save', '1');
return redirect(route('user_update_password'));
}
/**
* @return \Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Illuminate\View\View
* @return Factory|RedirectResponse|Redirector|View
*/
public function updatePasswordFirst(){
if(!Auth::user()->isPasswort()){
public function updatePasswordFirst()
{
if (! Auth::user()->isPasswort()) {
return view('user.update_password_first');
}
return redirect(route('user_update_password'));
}
public function updatePasswordFirstStore()
{
$rules = array(
'password' => 'required|string|min:6|confirmed',
);
$rules = [
'password' => ['required', 'string', 'confirmed', Password::defaults()],
];
$validator = Validator::make(Request::all(), $rules);
@ -88,6 +87,7 @@ class UserUpdatePasswordController extends Controller
// get the error messages from the validator
$messages = $validator->messages();
// redirect our user back to the form with the errors from the validator
return view('user.update_password_first')->withErrors($validator);
@ -95,12 +95,11 @@ class UserUpdatePasswordController extends Controller
$user = Auth::user();
$data = Request::all();
$user->fill([
'password' => Hash::make($data['password'])
'password' => Hash::make($data['password']),
])->save();
\Session()->flash('alert-save', '1');
return redirect('/home');
}
}
}

View file

@ -2,126 +2,65 @@
namespace App\Http\Controllers\Web;
use Request;
use Validator;
use App\Http\Controllers\Controller;
use App\Http\Requests\Contact\ContactStoreRequest;
use App\Mail\MailContact;
use App\Services\FormSpamGuard;
use App\Services\Shop;
use App\Services\Util;
use GuzzleHttp\Client;
use App\Mail\MailContact;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Mail;
class ContactController extends Controller
{
private $GOOGLE_ReCAPTCHA_KEY = "6LeeZosUAAAAAG907fMMqO4BFgsiR4ANDodd8FlU";
private $GOOGLE_ReCAPTCHA_SECRET = "6LeeZosUAAAAADIy2fyR4RG3EuM-Zdz7Pa2Qmb1J";
public function __construct(private FormSpamGuard $spamGuard) {}
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
}
public function create()
public function create(): View
{
$data = [
'GOOGLE_ReCAPTCHA_KEY' => $this->GOOGLE_ReCAPTCHA_KEY,
'turnstile_site_key' => config('services.turnstile.site_key'),
'form_token' => old(FormSpamGuard::TIMESTAMP_FIELD, $this->spamGuard->makeFormToken()),
'user_shop' => Util::getUserShop(),
'mylangs' => Shop::getLangChange('webshop'),
'yard_instance' => 'webshop',
];
return view('web.templates.kontakt', $data);
}
public function store()
/**
* Die Anfrage geht nur an mivita und ggf. den Berater des Shops, nie an die eingegebene Adresse.
*/
public function store(ContactStoreRequest $request): View
{
$blockReason = $this->spamGuard->blockReason($request);
$user_shop = Util::getUserShop();
$rules = array(
'salutation' => 'required',
'first_name' => 'required',
'last_name' => 'required',
'email' => 'required|email',
'message' => 'required',
'g-recaptcha-response' => 'required|recaptcha',
'accepted_data_protection' => 'required',
);
if (!$user_shop || $user_shop->id === 22) {
$rules['sales_partnership'] = 'required';
if (Request::get('sales_partnership') === 'JA') {
$rules['sales_partnership_message'] = 'required';
}
if ($blockReason !== null) {
FormSpamGuard::log($request, 'contact', $blockReason);
} else {
$this->sendMail($request);
}
Validator::extend('recaptcha', function ($attribute, $value, $parameters, $validator) {
return $this->reCaptcha_validate($attribute, $value, $parameters, $validator);
});
$validator = Validator::make(Request::all(), $rules);
if ($validator->fails()) {
return back()->withErrors($validator)->withInput(Request::all());
}
$contact = [];
$contact['salutation'] = Request::get('salutation');
$contact['first_name'] = Request::get('first_name');
$contact['last_name'] = Request::get('last_name');
$contact['email'] = Request::get('email');
$contact['phone'] = Request::get('phone');
$contact['message'] = Request::get('message');
if (!$user_shop){
$contact['sales_partnership'] = Request::get('sales_partnership');
$contact['sales_partnership_message'] = Request::get('sales_partnership_message');
}
$contact_mail = config('app.contact_mail');
if($user_shop){
Mail::to($contact['email'])->bcc([$user_shop->user->email, $contact_mail])->locale(\App::getLocale())->send(new MailContact($contact));
}else{
Mail::to($contact['email'])->bcc($contact_mail)->locale(\App::getLocale())->send(new MailContact($contact));
}
$data = [
'user_shop' => Util::getUserShop(),
'mylangs' => Shop::getLangChange('webshop'),
'yard_instance' => 'webshop',
];
return view('web.templates.contact-final', $data);
return view('web.templates.contact-final', [
'user_shop' => Util::getUserShop(),
'mylangs' => Shop::getLangChange('webshop'),
'yard_instance' => 'webshop',
]);
}
private function reCaptcha_validate($attribute, $value, $parameters, $validator)
private function sendMail(ContactStoreRequest $request): void
{
$contact = $request->safe()->only(['salutation', 'first_name', 'last_name', 'email', 'phone', 'message']);
if ($request->asksForSalesPartnership()) {
$contact['sales_partnership'] = $request->input('sales_partnership');
$contact['sales_partnership_message'] = $request->input('sales_partnership_message');
}
$client = new Client();
$mail = Mail::to(config('app.contact_mail'));
if ($consultantEmail = Util::getUserShop()?->user?->email) {
$mail->bcc($consultantEmail);
}
$response = $client->post(
'https://www.google.com/recaptcha/api/siteverify',
['form_params' =>
[
'secret' => $this->GOOGLE_ReCAPTCHA_SECRET,
'response' => $value
]
]
);
$body = json_decode((string)$response->getBody());
return $body->success;
$mail->locale(app()->getLocale())->send(new MailContact($contact));
}
}
}

View file

@ -3,23 +3,19 @@
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Requests\Register\RegisterUserRequest;
use App\Mail\MailVerifyAccount;
use App\Models\UserLevel;
use App\Repositories\UserRepository;
use App\Services\FormSpamGuard;
use App\Services\UserService;
use App\Services\Util;
use App\User;
use GuzzleHttp\Client;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Mail;
use Request;
use Validator;
class RegisterController extends Controller
{
private $GOOGLE_ReCAPTCHA_KEY = '6LeeZosUAAAAAG907fMMqO4BFgsiR4ANDodd8FlU';
private $GOOGLE_ReCAPTCHA_SECRET = '6LeeZosUAAAAADIy2fyR4RG3EuM-Zdz7Pa2Qmb1J';
protected $userRepo;
/**
@ -27,7 +23,7 @@ class RegisterController extends Controller
*
* @return void
*/
public function __construct(UserRepository $userRepo)
public function __construct(UserRepository $userRepo, private FormSpamGuard $spamGuard)
{
$this->userRepo = $userRepo;
}
@ -35,7 +31,7 @@ class RegisterController extends Controller
public function index()
{
if (config('app.debug')) {
\Log::channel('doamin')->debug('RegisterController: index - Session user_shop', [
\Log::channel('domain')->debug('RegisterController: index - Session user_shop', [
'session_user_shop_id' => \Session::get('user_shop')?->id,
'session_user_shop_name' => \Session::get('user_shop')?->name,
'session_user_shop_user_id' => \Session::get('user_shop')?->user_id,
@ -47,7 +43,8 @@ class RegisterController extends Controller
}
$data = [
'GOOGLE_ReCAPTCHA_KEY' => $this->GOOGLE_ReCAPTCHA_KEY,
'turnstile_site_key' => config('services.turnstile.site_key'),
'form_token' => old(FormSpamGuard::TIMESTAMP_FIELD, $this->spamGuard->makeFormToken()),
'user_shop' => Util::getUserShop(),
'yard_instance' => 'webshop',
];
@ -60,13 +57,12 @@ class RegisterController extends Controller
if (! $member_id) {
return redirect('/registrierung');
}
$user_id = (int) str_replace('m', '', $member_id) - config('mivita.add_number_id');
$user = User::find($user_id);
if (! $user || ! $user->isActive() || ! $user->isActiveAccount()) {
if (! $this->findActiveSponsor($member_id)) {
return redirect('/registrierung');
}
$data = [
'GOOGLE_ReCAPTCHA_KEY' => $this->GOOGLE_ReCAPTCHA_KEY,
'turnstile_site_key' => config('services.turnstile.site_key'),
'form_token' => old(FormSpamGuard::TIMESTAMP_FIELD, $this->spamGuard->makeFormToken()),
'user_shop' => Util::getUserShop(),
'from_member_id' => $member_id,
'yard_instance' => 'webshop',
@ -75,32 +71,17 @@ class RegisterController extends Controller
return view('web.templates.registrierung', $data);
}
public function register()
public function register(RegisterUserRequest $request): RedirectResponse
{
if ($blockReason = $this->spamGuard->blockReason($request)) {
FormSpamGuard::log($request, 'register', $blockReason);
$rules = [
'salutation' => 'required',
'first_name' => 'required',
'last_name' => 'required',
'email' => 'required|string|email:filter|max:255|unique:users',
'email-confirm' => 'required|same:email',
'password' => 'required|string|min:6|confirmed',
'password_confirmation' => 'required|string|min:6',
'g-recaptcha-response' => 'required|recaptcha',
'accepted_data_protection' => 'required',
];
Validator::extend('recaptcha', function ($attribute, $value, $parameters, $validator) {
return $this->reCaptcha_validate($attribute, $value, $parameters, $validator);
});
$validator = Validator::make(Request::all(), $rules);
if ($validator->fails()) {
return back()->withErrors($validator)->withInput(Request::all());
return redirect('/registrierung/finish');
}
$user_shop = Util::getUserShop();
$data = Request::all();
$data = $request->validated();
$data['lang'] = ! empty(\App::getLocale()) ? \App::getLocale() : 'de';
$user = $this->userRepo->create($data);
$confirmation_code = UserService::createConfirmationCode();
@ -108,10 +89,9 @@ class RegisterController extends Controller
if ($user_shop) {
$m_sponsor_id = $user_shop->user->id;
}
if (isset($data['from_member_id'])) {
$m_sponsor_id = (int) str_replace('m', '', $data['from_member_id']) - config('mivita.add_number_id');
if (filled($data['from_member_id'] ?? null) && $sponsor = $this->findActiveSponsor($data['from_member_id'])) {
$m_sponsor_id = $sponsor->id;
}
$user->lang = ! empty(\App::getLocale()) ? \App::getLocale() : 'de';
$user->confirmation_code = $confirmation_code;
$user->confirmation_code_to = date('Y-m-d H:i:s', strtotime('+1 week'));
$user->confirmation_code_remider = 0;
@ -144,23 +124,18 @@ class RegisterController extends Controller
return view('web.templates.registrierung_finish', $data);
}
private function reCaptcha_validate($attribute, $value, $parameters, $validator)
/**
* Löst eine Partner-Nummer (z. B. "m1234") zu einem aktiven Berater auf.
*/
private function findActiveSponsor(string $memberId): ?User
{
$userId = (int) str_replace('m', '', $memberId) - config('mivita.add_number_id');
$user = User::find($userId);
$client = new Client;
if (! $user || ! $user->isActive() || ! $user->isActiveAccount()) {
return null;
}
$response = $client->post(
'https://www.google.com/recaptcha/api/siteverify',
[
'form_params' => [
'secret' => $this->GOOGLE_ReCAPTCHA_SECRET,
'response' => $value,
],
]
);
$body = json_decode((string) $response->getBody());
return $body->success;
return $user;
}
}

View file

@ -21,6 +21,7 @@ use App\User;
use Auth;
use Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\Rules\Password;
use Request;
use Validator;
use Yard;
@ -489,7 +490,7 @@ class WizardController extends Controller
return redirect(route('wizard_create', [12]));
}
$rules = [
'password' => 'required|string|min:6|confirmed',
'password' => ['required', 'string', 'confirmed', Password::defaults()],
];
$validator = Validator::make(Request::all(), $rules);
if ($validator->fails()) {

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'));
}
};
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests\Register;
use App\Rules\PersonName;
use App\Rules\TurnstileToken;
use App\Services\FormSpamGuard;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class RegisterUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'salutation' => ['required', 'in:mr,ms'],
'first_name' => ['required', 'string', new PersonName],
'last_name' => ['required', 'string', new PersonName],
'email' => ['required', 'string', 'email:filter', 'max:255', 'unique:users'],
'email-confirm' => ['required', 'same:email'],
'password' => ['required', 'string', 'max:255', 'confirmed', Password::defaults()],
'password_confirmation' => ['required', 'string'],
'accepted_data_protection' => ['accepted'],
'from_member_id' => ['nullable', 'string', 'max:20'],
'cf-turnstile-response' => ['required', new TurnstileToken($this->ip())],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'cf-turnstile-response.required' => __('form_protection.captcha_failed'),
];
}
protected function failedValidation(Validator $validator): void
{
FormSpamGuard::log($this, 'register', 'validation', ['fields' => array_keys($validator->failed())]);
parent::failedValidation($validator);
}
}