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

@ -111,3 +111,8 @@ STRIPE_KEY=
STRIPE_WEBHOOK_SECRET=
# test | live — muss zum Key-Paar passen
STRIPE_MODE=test
# Cloudflare Turnstile (Captcha Kontaktformular, kostenlos): https://dash.cloudflare.com → Turnstile
# Lokal/Test: Cloudflare-Testschlüssel, die immer bestehen
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA

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

View file

@ -1,59 +1,59 @@
<?php
namespace App\Mail;
use App\User;
use App\Services\Util;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;
use App\Services\Util;
/**
* Kontaktanfrage an mivita bzw. den Berater. Antworten gehen per Reply-To direkt an den Absender.
*/
class MailContact extends Mailable
{
use Queueable, SerializesModels;
public $user_shop;
public $subject;
public $data;
/**
* @param array<string, mixed> $data
*/
public function __construct($data)
{
$this->data = $data;
$this->user_shop = Util::getUserShop();
$this->subject = __('email.your_request_from').' mivita.care';
if($this->user_shop){
$this->subject = __('email.your_request_from')." ".$this->user_shop->slug.'.mivita.care';
}
$this->subject = __('contact.mail_subject', ['source' => $this->source()]);
$this->replyTo($data['email'], trim(($data['first_name'] ?? '').' '.($data['last_name'] ?? '')));
}
public function build()
{
$salutation = __('email.hello');
if(isset($this->data['first_name'])){
$salutation .= " ".$this->data['first_name'];
}
if(isset($this->data['salutation'])){
if($this->data['salutation'] === 'mr'){
if (isset($this->data['salutation'])) {
if ($this->data['salutation'] === 'mr') {
$this->data['salutation'] = 'Herr';
}
if($this->data['salutation'] === 'ms'){
if ($this->data['salutation'] === 'ms') {
$this->data['salutation'] = 'Frau';
}
}
$copy1line = __('email.your_request_from')." mivita.care";
if($this->user_shop){
$copy1line = __('email.your_request_from')." ".$this->user_shop->slug.'.mivita.care';
}
return $this->view('emails.contact')->with([
'salutation' => $salutation,
'copy1line' => $copy1line,
'salutation' => __('contact.mail_headline'),
'copy1line' => __('contact.mail_intro', ['source' => $this->source()]),
'data' => $this->data,
'copy3line' => __('email.copy3line'),
'copy3line' => __('contact.mail_reply_hint'),
'greetings' => __('email.greetings'),
'sender' => __('email.sender'),
]);
}
}
private function source(): string
{
return $this->user_shop ? $this->user_shop->slug.'.mivita.care' : 'mivita.care';
}
}

View file

@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
@ -29,6 +30,8 @@ class AppServiceProvider extends ServiceProvider
{
Schema::defaultStringLength(191);
Validator::resolver(fn ($translator, $data, $rules, $messages, $attributes) => new SafeEmailValidator($translator, $data, $rules, $messages, $attributes));
// Mindestanforderung für alle neu gesetzten Passwörter (Registrierung, Wizard, Ändern, Zurücksetzen)
Password::defaults(fn () => Password::min(8));
Paginator::defaultView('pagination::bootstrap-4');
Paginator::defaultSimpleView('pagination::simple-bootstrap-4');

View file

@ -5,6 +5,8 @@ namespace App\Providers;
// use App\Domain\DomainContext; // HOTFIX: Temporär deaktiviert
use App\Domain\EarlyDomainParser;
use App\Http\Controllers\HealthController;
use App\Services\FormSpamGuard;
use Closure;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
@ -131,5 +133,38 @@ class RouteServiceProvider extends ServiceProvider
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
});
RateLimiter::for('contact-form', function (Request $request) {
$tooManyRequests = $this->formTooManyRequestsResponse('contact');
return [
Limit::perMinutes(10, 5)->by('contact-ip:'.$request->ip())->response($tooManyRequests),
Limit::perDay(5)->by('contact-email:'.mb_strtolower((string) $request->input('email')))->response($tooManyRequests),
];
});
RateLimiter::for('register-form', function (Request $request) {
$tooManyRequests = $this->formTooManyRequestsResponse('register');
return [
Limit::perMinutes(10, 5)->by('register-ip:'.$request->ip())->response($tooManyRequests),
Limit::perDay(20)->by('register-ip-day:'.$request->ip())->response($tooManyRequests),
];
});
}
/**
* Zurück zum Formular mit Hinweis statt nackter 429-Seite.
*
* @param 'contact'|'register' $form
*/
private function formTooManyRequestsResponse(string $form): Closure
{
return function (Request $request) use ($form) {
FormSpamGuard::log($request, $form, 'rate_limit');
return back()->withInput($request->except('password', 'password_confirmation'))
->withErrors(['email' => __('form_protection.too_many_requests')]);
};
}
}

View file

@ -51,10 +51,12 @@ class UserRepository extends BaseRepository
public function create($data)
{
$this->model = User::create([
$this->model = new User([
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
$this->model->lang = $data['lang'] ?? 'de';
$this->model->save();
$account = UserAccount::create([
'm_salutation' => $data['salutation'],

29
app/Rules/PersonName.php Normal file
View file

@ -0,0 +1,29 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
/**
* Vor- oder Nachname: nur lateinische Schrift (inkl. Umlaute/Akzente), Leerzeichen, Bindestrich,
* Apostroph und Punkt, max. 50 Zeichen. Hält Werbetexte und Links aus Namensfeldern fern,
* die sonst in Begrüßungen von Mails landen.
*/
class PersonName implements ValidationRule
{
public const PATTERN = "/^[\p{Latin}\p{M}][\p{Latin}\p{M} '.\-]*$/u";
public const MAX_LENGTH = 50;
/**
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || mb_strlen($value) > self::MAX_LENGTH || ! preg_match(self::PATTERN, $value)) {
$fail(__('form_protection.name_invalid'));
}
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Rules;
use App\Services\TurnstileVerifier;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class TurnstileToken implements ValidationRule
{
public function __construct(private ?string $ip = null) {}
/**
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! app(TurnstileVerifier::class)->verify($value, $this->ip)) {
$fail(__('form_protection.captcha_failed'));
}
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Services;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Unsichtbare Bot-Erkennung für öffentliche Formulare (Kontakt, Registrierung):
* Honeypot-Feld und Mindest-Ausfüllzeit.
*
* Erkannte Bots bekommen trotzdem die Erfolgsseite, damit sie nichts dazulernen.
*/
class FormSpamGuard
{
public const HONEYPOT_FIELD = 'form_extra';
public const TIMESTAMP_FIELD = 'form_token';
public const MIN_SECONDS = 3;
public function makeFormToken(): string
{
return encrypt(now()->timestamp);
}
/**
* Liefert den Grund, warum die Anfrage als Bot gilt, oder null.
*/
public function blockReason(Request $request): ?string
{
if (filled($request->input(self::HONEYPOT_FIELD))) {
return 'honeypot';
}
try {
$startedAt = (int) decrypt((string) $request->input(self::TIMESTAMP_FIELD));
} catch (DecryptException) {
return 'invalid_form_token';
}
if (now()->timestamp - $startedAt < self::MIN_SECONDS) {
return 'too_fast';
}
return null;
}
/**
* Protokolliert eine abgefangene Anfrage ohne Freitext-Inhalte.
*
* @param 'contact'|'register' $form
* @param array<string, mixed> $context
*/
public static function log(Request $request, string $form, string $reason, array $context = []): void
{
Log::channel('form_spam')->info('Formular abgefangen', [
'form' => $form,
'reason' => $reason,
'ip' => $request->ip(),
'host' => $request->getHost(),
'user_shop_id' => Util::getUserShop()?->id,
'email_domain' => Str::after((string) $request->input('email'), '@') ?: null,
...$context,
]);
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Prüft Cloudflare-Turnstile-Tokens (kostenloses Captcha) serverseitig.
*
* Schlägt die Prüfung technisch fehl (Cloudflare nicht erreichbar, kein Secret),
* gilt das Token als ungültig.
*/
class TurnstileVerifier
{
public const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
public function verify(?string $token, ?string $ip = null): bool
{
if (blank($token)) {
return false;
}
$secret = config('services.turnstile.secret_key');
if (blank($secret)) {
Log::channel('form_spam')->error('Turnstile: TURNSTILE_SECRET_KEY ist nicht gesetzt.');
return false;
}
try {
$response = Http::asForm()->timeout(5)->post(self::VERIFY_URL, [
'secret' => $secret,
'response' => $token,
'remoteip' => $ip,
]);
} catch (ConnectionException $e) {
Log::channel('form_spam')->warning('Turnstile: Cloudflare nicht erreichbar.', ['error' => $e->getMessage()]);
return false;
}
return $response->successful() && $response->json('success') === true;
}
}

View file

@ -96,6 +96,13 @@ return [
'level' => env('LOG_LEVEL', 'info'),
'days' => 30,
],
// Abgefangene Anfragen aus Kontakt- und Registrierungsformular (ohne Freitext)
'form_spam' => [
'driver' => 'daily',
'path' => storage_path('logs/form-spam.log'),
'level' => 'info',
'days' => 30,
],
'stack' => [
'driver' => 'stack',
'channels' => ['single'],

View file

@ -37,6 +37,12 @@ return [
'secret' => env('STRIPE_SECRET'),
],
// Cloudflare Turnstile (kostenloses Captcha) für das Kontaktformular
'turnstile' => [
'site_key' => env('TURNSTILE_SITE_KEY'),
'secret_key' => env('TURNSTILE_SECRET_KEY'),
],
'vies' => [
'enabled' => env('VIES_ENABLED', true),
'timeout' => env('VIES_TIMEOUT', 10),

View file

@ -0,0 +1,233 @@
# Spam über das Kontaktformular – Entwicklungsplan & Protokoll
Stand: 09.10.2026 · Status: **Kontaktformular + Registrierung umgesetzt, als `v1.6.0` getaggt** (Deploy siehe Abschnitt 7)
Beispiel-Mail: [spam-mail.md](spam-mail.md)
---
## 1. Befund
Betroffen ist das Kontaktformular `/kontakt`, das auf allen Shops läuft (Hauptseite, `shop.*`, alle Berater-Shops wie `freshzone.mivita.care`).
- Route: `routes/shared/common.php` (bzw. `routes/domains/main.php`) → `Web\ContactController@store`
- Mail: `App\Mail\MailContact`, Vorlage `resources/views/emails/contact.blade.php`
- Formular: `resources/views/web/templates/kontakt.blade.php`
### Was in der Beispiel-Mail passiert
Die Spam-Mail ist **keine Nachricht an uns**, sondern die **Kopie an den Absender** („Deine Anfrage von freshzone.mivita.care“). Der Controller schickt die Mail so:
```php
Mail::to($contact['email']) // ← Adresse aus dem Formular
->bcc([$user_shop->user->email, $contact_mail])
->send(new MailContact($contact));
```
Der Spammer trägt also die Adresse eines **fremden Opfers** ein (hier `igor.makhataev@icloud.com`) und seinen Werbetext in Vorname, Nachname und Nachricht. Unser Server verschickt dann eine Mail **im Namen von mivita.care** mit „Hallo <Werbetext>“ und dem Phishing-Link an das Opfer. Wir und der Berater bekommen nur die BCC-Kopie.
Das heißt:
1. **Unser Formular wird als Spam-Versender missbraucht.** Das ist der eigentliche Grund, warum das Formular für Spammer interessant ist – nicht, um uns zu erreichen.
2. **Ruf der Absender-Domain:** Beschwerden der Empfänger („Als Spam markieren“) landen bei unserer Domain. Das kann dazu führen, dass auch echte Mails (Bestellbestätigungen, Rechnungen, Abo-Mails) im Spam landen.
3. Jeder Berater mit Shop bekommt den Spam zusätzlich per BCC.
### Weitere Schwachstellen
| # | Schwachstelle | Fundstelle |
| - | ------------- | ---------- |
| A | Bestätigungsmail geht an beliebige eingegebene Adresse, inklusive aller Eingaben | `ContactController@store` |
| B | Kein Rate-Limit (nur globales `throttle:60,1` für die Web-Gruppe) | `routes/shared/common.php` |
| C | reCAPTCHA **v2 (Checkbox)** – wird von bezahlten Captcha-Lösediensten zuverlässig geknackt | `kontakt.blade.php` |
| D | reCAPTCHA-Schlüssel **inkl. Secret hart im Code** und damit im Git | `ContactController` (und `RegisterController`) |
| E | Keine Plausibilitätsprüfung: Vorname/Nachname ohne Längenbegrenzung, Links erlaubt, beliebige Schrift | Validierungsregeln |
| F | Eingaben werden **ungefiltert als HTML** in die Mail geschrieben (`{!! nl2br($value) !!}`) – man könnte auch Links/Bilder einschleusen | `emails/contact.blade.php` |
| G | Kein Protokoll: Wir sehen nicht, wie viele Versuche kommen und was geblockt wird | – |
| H | Validierung inline im Controller statt Form Request | `ContactController` |
---
## 2. Vorgeschlagene Maßnahmen
Reihenfolge nach Wirkung. Stufe 1 allein dürfte den größten Teil erledigen, weil das Formular für Spammer damit wertlos wird.
### Stufe 1 – sofort, höchste Wirkung
**1.1 Keine Inhalte mehr an die eingegebene Adresse schicken** (behebt A)
- Die Anfrage geht nur noch an `kontakt@mivita.care` bzw. den Berater, mit `Reply-To` = Adresse des Absenders (Antworten funktionieren wie bisher).
- Der Absender sieht die Bestätigungsseite. Falls eine Bestätigungsmail gewünscht ist: nur ein **neutraler Text ohne Eingaben** („Wir haben Deine Anfrage erhalten …“), siehe Entscheidung E1.
**1.2 Rate-Limit** (behebt B)
- Eigener Limiter für `POST /kontakt`, z. B. max. 3 Anfragen / 10 Minuten pro IP und max. 3 / Tag pro E-Mail-Adresse.
**1.3 Honeypot + Mindest-Ausfüllzeit** (unsichtbar für echte Besucher)
- Verstecktes Feld, das nur Bots ausfüllen.
- Verschlüsselter Zeitstempel beim Laden des Formulars; Absenden schneller als ~3 Sekunden → verwerfen.
- Abgefangene Anfragen bekommen trotzdem die normale Erfolgsseite, damit der Bot nichts lernt.
**1.4 Plausibilitätsregeln** (behebt E)
- Vorname/Nachname: max. 50 Zeichen, keine URLs, keine Zeilenumbrüche, nur lateinische Schrift inkl. Umlaute/Akzente (Shops laufen auf de/en/es/fr).
- Nachricht: max. ca. 3.000 Zeichen, max. 1–2 Links.
- Telefon: nur Ziffern, Leerzeichen, `+ - / ( )`.
- Vorname = Nachname = Nachricht (wie im Beispiel) → verwerfen.
**1.5 Mail-Vorlage absichern** (behebt F)
- `{!! nl2br($value) !!}` → `{!! nl2br(e($value)) !!}`.
### Stufe 2 – Aufräumen & Absichern
**2.1 Captcha** (behebt C, D) – siehe Entscheidung E2
- Schlüssel in `.env` / `config/services.php` verschieben, **Secret bei Google neu erzeugen** (das alte liegt im Git).
- Bei der Prüfung zusätzlich `hostname` kontrollieren.
- Optional Wechsel auf Cloudflare Turnstile oder reCAPTCHA v3 (unsichtbar, schwerer automatisiert zu lösen).
**2.2 Form Request** (behebt H)
- `ContactStoreRequest` mit Regeln und Fehlermeldungen (de/en/es/fr), Controller verschlanken.
**2.3 Protokoll geblockter Anfragen** (behebt G) – siehe Entscheidung E4
- Eigener Log-Kanal `contact-spam` (Grund, IP, Shop, Zeit – **ohne** Nachrichtentext wegen Datenschutz) oder kleine DB-Tabelle mit Zählern.
- Damit sehen wir nach der Umstellung, ob es wirkt, und können Regeln nachschärfen.
### Stufe 3 – nur falls nötig
- Wortfilter / Sperrliste für Domains und Linkdienste (z. B. `share.google`, Kurz-URLs).
- Gleiche Prüfung auf das Registrierungsformular übertragen (nutzt dieselben reCAPTCHA-Schlüssel).
---
## 3. Entscheidungen (abgestimmt am 09.10.2026)
| # | Frage | Entscheidung |
| - | ----- | ------------ |
| E1 | Bestätigungsmail an den Absender? | **Nein.** Nur Bestätigungsseite. Die Anfrage geht an `kontakt@mivita.care` (+ Berater per BCC), `Reply-To` = Absender. |
| E2 | Captcha | **Wechsel auf Cloudflare Turnstile.** Vorgabe: kostenlos. Turnstile ist dauerhaft kostenlos, ohne Mengenlimit und ohne Zahlungsdaten; die Seite muss nicht über Cloudflare laufen. reCAPTCHA v3 läuft inzwischen über Google Cloud (nur 10.000 Prüfungen/Monat frei, Cloud-Projekt nötig) und ist datenschutzrechtlich ungünstiger. |
| E3 | Nicht-lateinische Schrift in Namensfeldern blocken? | **Ja.** |
| E4 | Protokoll | **Logdatei** `storage/logs/contact-spam-YYYY-MM-DD.log` (30 Tage). Admin-Übersicht nur bei Bedarf. |
| E5 | Berater bekommen weiter eine Kopie? | **Ja**, per BCC. |
---
## 4. Umsetzung Kontaktformular (Stand 09.10.2026)
### Was jetzt passiert, wenn jemand das Formular abschickt
1. **Rate-Limit** (`throttle:contact-form`, `RouteServiceProvider`): max. 5 Anfragen / 10 Min. pro IP und max. 5 / Tag pro E-Mail-Adresse. Darüber: Fehlermeldung am Formular.
2. **Validierung** (`App\Http\Requests\Contact\ContactStoreRequest`):
- Vor-/Nachname (`App\Rules\PersonName`): max. 50 Zeichen, nur lateinische Buchstaben inkl. Umlaute/Akzente, Leerzeichen, `- ' .`
- Telefon: max. 30 Zeichen, nur `0-9 + - / ( ) .` und Leerzeichen
- Nachricht: max. 3.000 Zeichen, max. 2 Links
- Turnstile-Token wird bei Cloudflare geprüft (`App\Rules\TurnstileToken` → `App\Services\TurnstileVerifier`). Ist Cloudflare nicht erreichbar oder fehlt das Secret → abgelehnt.
3. **Unsichtbare Bot-Erkennung** (`App\Services\FormSpamGuard`): verstecktes Feld `form_extra` (Honeypot) und verschlüsselter Zeitstempel `form_token` (Absenden schneller als 3 Sek. → Bot). Bots bekommen die normale Erfolgsseite, es wird aber nichts verschickt.
4. **Versand** (`App\Mail\MailContact`): an `config('app.contact_mail')`, Berater des Shops per BCC, `Reply-To` = Absender. Betreff: „Neue Kontaktanfrage über <shop>.mivita.care“. **Keine Mail mehr an die eingegebene Adresse.**
5. Eingaben werden in der Mail HTML-escaped (`resources/views/emails/contact.blade.php`).
---
## 5. Umsetzung Registrierung (Stand 09.10.2026)
Die Registrierung (`/registrierung`, `/reg/{partnernummer}` auf Hauptseite, `shop.*` und Berater-Shops) hatte dieselben Schwächen. Zusätzlich verschickt sie eine Bestätigungsmail an die eingegebene Adresse **mit dem Vornamen in der Anrede** – ohne Namensprüfung hätte sich auch das als Spam-Versender missbrauchen lassen.
1. **Turnstile statt reCAPTCHA** (`data-action="register"`). Damit ist reCAPTCHA im ganzen System entfernt; die alten Schlüssel stehen nicht mehr im Code.
2. **Rate-Limit** (`throttle:register-form`): max. 5 Versuche / 10 Min. und 20 / Tag pro IP (großzügiger, weil Berater auf Veranstaltungen mehrere Partner aus einem WLAN registrieren).
3. **Validierung** (`App\Http\Requests\Register\RegisterUserRequest`): Namensregel `PersonName` wie beim Kontaktformular, Anrede nur `mr`/`ms`, Datenschutz muss akzeptiert sein. Passwort min. 8 Zeichen (systemweit, siehe unten).
4. **Honeypot + Mindestzeit** (`FormSpamGuard`): Bots werden auf die Erfolgsseite geleitet, es wird kein Benutzer angelegt und keine Mail verschickt.
5. **Sponsor-Manipulation verhindert:** Die Partner-Nummer `from_member_id` wurde beim Absenden ungeprüft übernommen – man konnte sich jedem beliebigen (auch gesperrten) Berater unterhängen. Jetzt gilt sie nur, wenn der Berater aktiv ist und einen gültigen Account hat; sonst greift wie bisher Shop-Inhaber bzw. Firma.
6. Nur noch geprüfte Felder werden an `UserRepository::create()` übergeben; die Sprache wird direkt beim Anlegen gesetzt.
### Passwort-Mindestlänge systemweit 8 Zeichen
Zentral in `AppServiceProvider::boot()`: `Password::defaults(fn () => Password::min(8))`. Alle Stellen, an denen ein neues Passwort gesetzt wird, nutzen `Password::defaults()`: Registrierung, Wizard (Schritt 11), erstes Passwort nach Freischaltung, Passwort ändern, Passwort zurücksetzen (Laravel-Standard). Vorher galten teils 6, teils 8 Zeichen. Bestehende kürzere Passwörter funktionieren beim Login weiter – die Regel greift nur beim Setzen. Texte in `resources/lang/*/passwords.php` angepasst. Tests: `tests/Feature/PasswordPolicyTest.php`.
### Sicherheitslücke im CRM geschlossen
`Auth::routes()` in `routes/domains/crm.php` hat neben Login auch `POST /register` auf `my.mivita.care` registriert. Das Formular dazu leitete zwar zum Login um, **der POST selbst legte aber ohne Captcha einen Benutzer an und meldete ihn direkt an** (auf dem Dev-System geprüft: Antwort 419 = Route aktiv, nur CSRF fehlte). Jetzt `Auth::routes(['register' => false])`; der Link „Register“ in `layouts/app.blade.php` ist entfernt.
Empfehlung: Auf Live einmal prüfen, ob es Benutzer **ohne `account_id`** gibt – das wären Konten, die über diesen Weg angelegt wurden:
```sql
SELECT id, email, created_at FROM users WHERE account_id IS NULL OR account_id = 0 ORDER BY created_at DESC;
```
---
## 6. Gemeinsames Protokoll & Stellschrauben
### Protokoll abgefangener Anfragen
`storage/logs/form-spam-*.log` (30 Tage), eine Zeile pro Vorfall mit `form` (`contact` / `register`), `reason`, IP, Host, Shop-ID, Domain der E-Mail-Adresse – kein Freitext, keine Passwörter.
| reason | Bedeutung |
| ------ | --------- |
| `validation` | Validierung fehlgeschlagen, `fields` nennt die Felder (betrifft auch echte Tippfehler) |
| `rate_limit` | zu viele Anfragen |
| `honeypot` | verstecktes Feld ausgefüllt |
| `too_fast` | schneller als 3 Sek. abgeschickt |
| `invalid_form_token` | Zeitstempel fehlt/manipuliert (Formular nicht über die Seite geladen) |
Auswertung auf dem Server (Bash):
```bash
grep -ho '"form":"[a-z]*","reason":"[a-z_]*"' storage/logs/form-spam-*.log | sort | uniq -c
```
### Stellschrauben für spätere Anpassungen
| Was | Wo |
| --- | -- |
| Rate-Limits | `RouteServiceProvider::configureRateLimiting()` → `contact-form`, `register-form` |
| Namensregel | `App\Rules\PersonName` (Muster, max. Länge) |
| Telefon-Muster, max. Links (Kontakt) | Konstanten in `ContactStoreRequest` |
| Mindest-Ausfüllzeit, Feldnamen | `FormSpamGuard` |
| Fehlermeldungen | `resources/lang/*/form_protection.php` (gemeinsam), `resources/lang/*/contact.php` (Kontakt + Mailtexte) |
Hinweis Routing: Auf der Hauptdomain überschreibt `routes/domains/main.php` die Kontakt-Route aus `routes/shared/common.php`. Änderungen an der Route deshalb **an beiden Stellen** machen. Die Registrierungs-Route steht in `main.php`, `shop.php` und `user-shop.php`.
### Geänderte / neue Dateien
- Controller: `Web/ContactController.php`, `Web/RegisterController.php`
- Requests: `Requests/Contact/ContactStoreRequest.php`, `Requests/Register/RegisterUserRequest.php` (neu)
- Regeln/Services: `Rules/PersonName.php`, `Rules/TurnstileToken.php`, `Services/TurnstileVerifier.php`, `Services/FormSpamGuard.php` (neu)
- `Mail/MailContact.php`, `Repositories/UserRepository.php` (`create`: Sprache beim Anlegen)
- Views: `web/templates/kontakt.blade.php`, `web/templates/registrierung.blade.php`, `emails/contact.blade.php`, `layouts/app.blade.php`
- Routen: `RouteServiceProvider.php`, `routes/shared/common.php`, `routes/domains/{main,shop,user-shop,crm}.php`
- Config: `config/services.php` (`turnstile`), `config/logging.php` (`form_spam`), `.env.example`
- Sprachen: `resources/lang/{de,en,es,fr}/contact.php`, `form_protection.php` (neu)
- Passwort: `AppServiceProvider.php`, `UserUpdatePasswordController.php`, `WizardController.php`, `Auth/RegisterController.php`, `resources/lang/*/passwords.php`
- Tests: `tests/Feature/ContactFormSpamTest.php` (26), `tests/Feature/RegisterFormSpamTest.php` (16), `tests/Feature/PasswordPolicyTest.php` (5)
---
## 7. Deployment – vor dem Go-Live erledigen
1. **Turnstile-Widget anlegen** (kostenlos): https://dash.cloudflare.com → Turnstile → „Add widget“
- Hostnames: `mivita.care`, `mivita.shop` (Subdomains wie `freshzone.mivita.care` sind automatisch abgedeckt)
- Widget Mode: **Managed**
2. Schlüssel in die Live-`.env` eintragen:
```
TURNSTILE_SITE_KEY=...
TURNSTILE_SECRET_KEY=...
```
✅ Widget angelegt am 09.10.2026. **Ohne diese Schlüssel lehnen Kontaktformular und Registrierung jede Anfrage ab.** Lokal sind die Cloudflare-Testschlüssel eingetragen, die immer bestehen.
3. **Datenschutzerklärung** anpassen: Google reCAPTCHA raus, Cloudflare Turnstile (Kontakt + Registrierung) rein.
4. Deploy wie üblich per Tag (`dev/betrieb/README.md`), keine Migration: `bash dev/betrieb/deploy.sh v1.6.0`
5. Danach den alten reCAPTCHA-Schlüssel in der Google-Konsole löschen (das Secret liegt in der Git-Historie).
6. SQL-Abfrage aus Abschnitt 5 (Benutzer ohne Account) auf Live ausführen.
7. Nach ein paar Tagen das Log auswerten (Befehl oben) und hier im Protokoll eintragen.
### Noch offen (nur bei Bedarf)
- Wortfilter / Sperrliste für Linkdienste, falls trotzdem Spam durchkommt.
---
## 8. Protokoll
| Datum | Was | Version |
| ----- | --- | ------- |
| 09.10.2026 | Analyse, Plan erstellt (dieses Dokument) | – |
| 09.10.2026 | Entscheidungen E1–E5 abgestimmt (E2: Turnstile statt reCAPTCHA, weil kostenlos) | – |
| 09.10.2026 | Stufe 1 + 2 umgesetzt, 26 Tests grün (`tests/Feature/ContactFormSpamTest.php`) | noch nicht deployt |
| 09.10.2026 | Registrierung auf Turnstile + gleiche Schutzschichten, Sponsor-Prüfung, offenes `POST /register` im CRM geschlossen; Schutz-Bausteine verallgemeinert (`FormSpamGuard`, `PersonName`, Log `form-spam`), 15 Tests grün (`tests/Feature/RegisterFormSpamTest.php`) | noch nicht deployt |
| 09.10.2026 | Passwort-Mindestlänge systemweit auf 8 Zeichen (zentral über `Password::defaults()`), Turnstile-Widget bei Cloudflare angelegt | noch nicht deployt |
| 09.10.2026 | Echte Turnstile-Schlüssel lokal geprüft: Secret von Cloudflare akzeptiert, Site-Key auf Hauptseite und Berater-Shop ausgeliefert, gefälschtes Token wird abgelehnt und protokolliert | noch nicht deployt |
| 09.10.2026 | Auf dem Testsystem im Browser geprüft (läuft), Commit + Tag für den Deploy | v1.6.0 |

View file

@ -0,0 +1,24 @@
Hallo Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL Всего доброго, команда сайта
Deine Anfrage von freshzone.mivita.care
Anrede:
Frau
Vorname:
Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL
Всего доброго, команда сайта
Nachname:
Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. Бонус будет аннулирован через 10 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/O8hzS8W7vPuroPXdL
Всего доброго, команда сайта
E-Mail:
igor.makhataev@icloud.com
Telefon:
88538273161
Nachricht:
Приветствуем! Напоминаем, что на вашем бонусном счёте находится 918USD. Бонус будет аннулирован через 11 дней. Воспользоваться бонусом или вывести средства вы можете по ссылке: https://share.google/SzivlySgl5DxoQgWn
Всего доброго, команда сайта
Für weitere Fragen stehen wir Dir gerne zur Verfügung.
Mit freundlichen Grüßen

View file

@ -0,0 +1,10 @@
<?php
return [
'phone_invalid' => 'Bitte gib eine gültige Telefonnummer ein.',
'too_many_links' => 'Die Nachricht enthält zu viele Links.',
'mail_subject' => 'Neue Kontaktanfrage über :source',
'mail_headline' => 'Neue Kontaktanfrage',
'mail_intro' => 'Über das Kontaktformular von :source ist folgende Anfrage eingegangen.',
'mail_reply_hint' => 'Antworte einfach auf diese E-Mail, um den Absender direkt zu erreichen.',
];

View file

@ -0,0 +1,8 @@
<?php
return [
'captcha_failed' => 'Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.',
'name_invalid' => 'Bitte gib einen gültigen Namen ein (nur Buchstaben, max. 50 Zeichen).',
'too_many_requests' => 'Zu viele Anfragen. Bitte versuche es später erneut.',
'honeypot_label' => 'Bitte dieses Feld leer lassen',
];

View file

@ -1,9 +1,9 @@
<?php
return array (
'password' => 'Passwörter müssen mindestens 6 Zeichen lang sein und korrekt bestätigt werden.',
'reset' => 'Das Passwort wurde zurückgesetzt!',
'sent' => 'E-Mail zum Zurücksetzen Ihres Passworts wurde versendet',
'token' => 'Der Passwort-Wiederherstellungs-Schlüssel ist ungültig oder abgelaufen.',
'user' => 'Es konnte leider kein Nutzer mit dieser E-Mail-Adresse gefunden werden.',
);
return [
'password' => 'Passwörter müssen mindestens 8 Zeichen lang sein und korrekt bestätigt werden.',
'reset' => 'Das Passwort wurde zurückgesetzt!',
'sent' => 'E-Mail zum Zurücksetzen Ihres Passworts wurde versendet',
'token' => 'Der Passwort-Wiederherstellungs-Schlüssel ist ungültig oder abgelaufen.',
'user' => 'Es konnte leider kein Nutzer mit dieser E-Mail-Adresse gefunden werden.',
];

View file

@ -0,0 +1,10 @@
<?php
return [
'phone_invalid' => 'Please enter a valid phone number.',
'too_many_links' => 'The message contains too many links.',
'mail_subject' => 'New contact request via :source',
'mail_headline' => 'New contact request',
'mail_intro' => 'The following request was submitted via the contact form on :source.',
'mail_reply_hint' => 'Simply reply to this email to contact the sender directly.',
];

View file

@ -0,0 +1,8 @@
<?php
return [
'captcha_failed' => 'The security check failed. Please try again.',
'name_invalid' => 'Please enter a valid name (letters only, max. 50 characters).',
'too_many_requests' => 'Too many requests. Please try again later.',
'honeypot_label' => 'Please leave this field empty',
];

View file

@ -1,9 +1,9 @@
<?php
return array (
'password' => 'Passwords must be at least six characters and match the confirmation.',
'reset' => 'Your password has been reset!',
'sent' => 'We have e-mailed your password reset link!',
'token' => 'This password reset token is invalid.',
'user' => 'We can\'t find a user with that e-mail address.',
);
return [
'password' => 'Passwords must be at least eight characters and match the confirmation.',
'reset' => 'Your password has been reset!',
'sent' => 'We have e-mailed your password reset link!',
'token' => 'This password reset token is invalid.',
'user' => 'We can\'t find a user with that e-mail address.',
];

View file

@ -0,0 +1,10 @@
<?php
return [
'phone_invalid' => 'Por favor, introduce un número de teléfono válido.',
'too_many_links' => 'El mensaje contiene demasiados enlaces.',
'mail_subject' => 'Nueva solicitud de contacto a través de :source',
'mail_headline' => 'Nueva solicitud de contacto',
'mail_intro' => 'Se ha recibido la siguiente solicitud a través del formulario de contacto de :source.',
'mail_reply_hint' => 'Responde simplemente a este correo para contactar directamente con el remitente.',
];

View file

@ -0,0 +1,8 @@
<?php
return [
'captcha_failed' => 'La verificación de seguridad ha fallado. Por favor, inténtalo de nuevo.',
'name_invalid' => 'Por favor, introduce un nombre válido (solo letras, máx. 50 caracteres).',
'too_many_requests' => 'Demasiadas solicitudes. Por favor, inténtalo más tarde.',
'honeypot_label' => 'Por favor, deja este campo vacío',
];

View file

@ -1,9 +1,9 @@
<?php
return array (
'password' => 'Las contraseñas deben tener al menos 6 caracteres y deben confirmarse correctamente.',
'reset' => '¡La contraseña ha sido restablecida!',
'sent' => 'Se ha enviado un correo electrónico para restablecer su contraseña.',
'token' => 'La clave de recuperación de contraseña no es válida o ha caducado.',
'user' => 'Lamentablemente no se pudo encontrar ningún usuario con esta dirección de correo electrónico.',
);
return [
'password' => 'Las contraseñas deben tener al menos 8 caracteres y deben confirmarse correctamente.',
'reset' => '¡La contraseña ha sido restablecida!',
'sent' => 'Se ha enviado un correo electrónico para restablecer su contraseña.',
'token' => 'La clave de recuperación de contraseña no es válida o ha caducado.',
'user' => 'Lamentablemente no se pudo encontrar ningún usuario con esta dirección de correo electrónico.',
];

View file

@ -0,0 +1,10 @@
<?php
return [
'phone_invalid' => 'Veuillez saisir un numéro de téléphone valide.',
'too_many_links' => 'Le message contient trop de liens.',
'mail_subject' => 'Nouvelle demande de contact via :source',
'mail_headline' => 'Nouvelle demande de contact',
'mail_intro' => 'La demande suivante a été envoyée via le formulaire de contact de :source.',
'mail_reply_hint' => 'Répondez simplement à cet e-mail pour contacter directement l’expéditeur.',
];

View file

@ -0,0 +1,8 @@
<?php
return [
'captcha_failed' => 'La vérification de sécurité a échoué. Veuillez réessayer.',
'name_invalid' => 'Veuillez saisir un nom valide (lettres uniquement, 50 caractères max.).',
'too_many_requests' => 'Trop de demandes. Veuillez réessayer plus tard.',
'honeypot_label' => 'Veuillez laisser ce champ vide',
];

View file

@ -1,7 +1,7 @@
<?php
return [
'password' => 'Les mots de passe doivent contenir au moins 6 caractères et être correctement confirmés.',
'password' => 'Les mots de passe doivent contenir au moins 8 caractères et être correctement confirmés.',
'reset' => 'Le mot de passe a été réinitialisé !',
'sent' => 'L’e-mail de réinitialisation de votre mot de passe a été envoyé.',
'token' => 'La clé de récupération du mot de passe est invalide ou a expiré.',

View file

@ -154,7 +154,7 @@
{{__('email.'.$key)}}: &nbsp;
</td>
<td style="color:#37302d; text-align: left; vertical-align: top;line-height:1.6em;" align="center">
{!! nl2br($value) !!}
{!! nl2br(e($value)) !!}
</td>
</tr>
@endforeach

View file

@ -40,9 +40,6 @@
<li class="nav-item">
<a class="nav-link" href="{{ route('login') }}">{{ __('Login') }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('register') }}">{{ __('Register') }}</a>
</li>
@else
<li class="nav-item dropdown">
<a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre>

View file

@ -74,7 +74,7 @@
</style>
<!-- /PAGE HEADER -->
<script src='https://www.google.com/recaptcha/api.js?explicit&hl={{ \App::getLocale() }}'></script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<!-- -->
<section>
@ -87,6 +87,11 @@
<div class="box-static box-transparent box-bordered padding-20">
{!! Form::open(['action' => '/kontakt']) !!}
{{ html()->hidden(\App\Services\FormSpamGuard::TIMESTAMP_FIELD, $form_token) }}
<div style="position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden;" aria-hidden="true">
<label for="{{ \App\Services\FormSpamGuard::HONEYPOT_FIELD }}">{{ __('form_protection.honeypot_label') }}</label>
{{ html()->text(\App\Services\FormSpamGuard::HONEYPOT_FIELD)->attributes(['tabindex' => '-1', 'autocomplete' => 'off']) }}
</div>
<div class="text-right" style="margin-bottom: 8px; margin-top: -16px;">
<em class="small">* {{trans('register.required_fields')}}</em>
</div>
@ -129,7 +134,12 @@
</div>
<div class="form-group col-md-12">
{!! Form::label('phone', __('Phone')) !!}
{!! Form::text('phone', null, ['class' => 'form-control', 'placeholder'=>__('Phone')]) !!}
{!! Form::text('phone', null, ['class' => 'form-control '.($errors->has('phone') ? 'error is-invalid' : ''), 'placeholder'=>__('Phone')]) !!}
@if ($errors->has('phone'))
<div class="alert alert-danger error"><!-- DANGER -->
{{ $errors->first('phone') }}
</div>
@endif
</div>
</div>
<div class="row contact-row">
@ -208,10 +218,10 @@
<div class="row contact-row">
<div class="col-md-12">
<div class="g-recaptcha" data-sitekey="{{$GOOGLE_ReCAPTCHA_KEY}}"></div>
@if ($errors->has('g-recaptcha-response'))
<div class="cf-turnstile" data-sitekey="{{ $turnstile_site_key }}" data-action="contact" data-language="{{ app()->getLocale() }}"></div>
@if ($errors->has('cf-turnstile-response'))
<div class="alert alert-danger error"><!-- DANGER -->
{{ $errors->first('g-recaptcha-response') }}
{{ $errors->first('cf-turnstile-response') }}
</div>
@endif
</div>

View file

@ -70,7 +70,7 @@
}
</style>
<script src='https://www.google.com/recaptcha/api.js?explicit&hl={{ \App::getLocale() }}'></script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<section>
<div class="container">
@ -87,6 +87,11 @@
<div class="box-static box-transparent box-bordered padding-20">
{!! Form::open(['action' => '/registrierung']) !!}
{{ html()->hidden(\App\Services\FormSpamGuard::TIMESTAMP_FIELD, $form_token) }}
<div style="position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden;" aria-hidden="true">
<label for="{{ \App\Services\FormSpamGuard::HONEYPOT_FIELD }}">{{ __('form_protection.honeypot_label') }}</label>
{{ html()->text(\App\Services\FormSpamGuard::HONEYPOT_FIELD)->attributes(['tabindex' => '-1', 'autocomplete' => 'off']) }}
</div>
@if(isset($from_member_id))
{!! Form::hidden('from_member_id', $from_member_id) !!}
@endif
@ -189,12 +194,12 @@
<div class="row contact-row">
<div class="col-md-12">
<div class="g-recaptcha" data-sitekey="{{$GOOGLE_ReCAPTCHA_KEY}}"></div>
@if ($errors->has('g-recaptcha-response'))
<div class="cf-turnstile" data-sitekey="{{ $turnstile_site_key }}" data-action="register" data-language="{{ app()->getLocale() }}"></div>
@if ($errors->has('cf-turnstile-response'))
<div class="alert alert-danger error"><!-- DANGER -->
{{ $errors->first('g-recaptcha-response') }}
{{ $errors->first('cf-turnstile-response') }}
</div>
@endif
@endif
</div>
</div>

View file

@ -10,7 +10,8 @@
/* ROUTING FOR CRM my.mivita / CMS */
Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_care'))->group(function () {
Auth::routes();
// Selbstregistrierung im CRM ist aus (Formular leitet zum Login) — POST /register darf es daher auch nicht geben
Auth::routes(['register' => false]);
Route::get('/logout', function () {
Auth::logout();

View file

@ -7,7 +7,6 @@
|
*/
Route::get('/datenschutz', 'HomeController@legalDataProtected')->name('datenschutz');
Route::get('/impressum', 'HomeController@legalImprint')->name('impressum');
Route::get('/datenschutz', 'HomeController@legalDataProtected')->name('data_protected');
@ -15,7 +14,7 @@ Route::get('/impressum', 'HomeController@legalImprint')->name('imprint');
Route::get('/agb', 'HomeController@legalAGB')->name('agb');
Route::get('/kontakt', 'Web\ContactController@create')->name('contact_create');
Route::post('/kontakt', 'Web\ContactController@store')->name('contact_store');
Route::post('/kontakt', 'Web\ContactController@store')->middleware('throttle:contact-form')->name('contact_store');
// Public DHL Tracking
Route::get('/tracking', 'DhlShipmentController@track')->name('public.tracking');
@ -23,9 +22,8 @@ Route::post('/tracking', 'DhlShipmentController@track')->name('public.tracking.c
Route::get('/registrierung', 'Web\RegisterController@index')->name('main.register_user');
Route::get('/reg/{member_id?}', 'Web\RegisterController@member')->name('main.register_user_member');
Route::post('/registrierung', 'Web\RegisterController@register')->name('main.register_user_post');
Route::post('/registrierung', 'Web\RegisterController@register')->middleware('throttle:register-form')->name('main.register_user_post');
Route::get('/registrierung/finish', 'Web\RegisterController@finish')->name('main.register_user_finish');
Route::get('/', 'Web\SiteController@index')->name('/');
Route::get('/{site}/{subsite?}/{product_slug?}', 'Web\SiteController@site')->name('base.site');

View file

@ -10,7 +10,7 @@ Route::get('/home', 'Web\SiteController@index')->name('shop.home');
Route::get('/registrierung', 'Web\RegisterController@index')->name('shop.register_user');
Route::get('/reg/{member_id?}', 'Web\RegisterController@member')->name('shop.register_user_member');
Route::post('/registrierung', 'Web\RegisterController@register')->name('shop.register_user_post');
Route::post('/registrierung', 'Web\RegisterController@register')->middleware('throttle:register-form')->name('shop.register_user_post');
Route::get('/registrierung/finish', 'Web\RegisterController@finish')->name('shop.register_user_finish');
Route::get('/', 'Web\SiteController@index')->name('shop.home');

View file

@ -10,7 +10,7 @@ Route::get('/home', 'Web\SiteController@index')->name('user-shop.home.alt');
Route::get('/registrierung', 'Web\RegisterController@index')->name('user-shop.register_user');
Route::get('/reg/{member_id?}', 'Web\RegisterController@member')->name('user-shop.register_user_member');
Route::post('/registrierung', 'Web\RegisterController@register')->name('user-shop.register_user_post');
Route::post('/registrierung', 'Web\RegisterController@register')->middleware('throttle:register-form')->name('user-shop.register_user_post');
Route::get('/registrierung/finish', 'Web\RegisterController@finish')->name('user-shop.register_user_finish');
Route::get('/user/card/add/{id}/{quantity?}/{product_slug?}', 'Web\CardController@addToCardGet')->name('user-shop.card_add_get');

View file

@ -55,7 +55,7 @@ if (EarlyDomainParser::getCurrentDomainType() === 'checkout') {
Route::get('/impressum', 'HomeController@legalImprint')->name('legal.imprint');
Route::get('/agb', 'HomeController@legalAGB')->name('legal.agb');
Route::get('/kontakt', 'Web\ContactController@create')->name('contact.create');
Route::post('/kontakt', 'Web\ContactController@store')->name('contact.store');
Route::post('/kontakt', 'Web\ContactController@store')->middleware('throttle:contact-form')->name('contact.store');
Route::get('/zahlungsarten', 'HomeController@zahlungsarten')->name('zahlungsarten');
Route::get('/versandkosten', 'HomeController@versandkosten')->name('versandkosten');
}

View file

@ -0,0 +1,240 @@
<?php
use App\Http\Middleware\SubdomainResolver;
use App\Mail\MailContact;
use App\Models\UserShop;
use App\Services\FormSpamGuard;
use App\Services\TurnstileVerifier;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
config([
'app.contact_mail' => 'kontakt@mivita.test',
'services.turnstile.site_key' => 'site-key',
'services.turnstile.secret_key' => 'secret-key',
]);
Mail::fake();
$this->turnstileResponse = fn () => Http::response(['success' => true]);
Http::fake(['challenges.cloudflare.com/*' => fn () => ($this->turnstileResponse)()]);
});
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function contactPayload(array $overrides = []): array
{
return array_merge([
'salutation' => 'ms',
'first_name' => 'Anna',
'last_name' => 'Müller',
'email' => 'anna@example.com',
'phone' => '+49 (0) 171 / 123-456',
'message' => 'Ich habe eine Frage zu euren Produkten.',
'sales_partnership' => 'NEIN',
'accepted_data_protection' => '1',
'cf-turnstile-response' => 'token',
FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp - 10),
FormSpamGuard::HONEYPOT_FIELD => '',
], $overrides);
}
it('zeigt das Formular mit Turnstile statt reCAPTCHA', function () {
$this->get('/kontakt')
->assertOk()
->assertSee('cf-turnstile', false)
->assertSee('data-sitekey="site-key"', false)
->assertSee(FormSpamGuard::HONEYPOT_FIELD, false)
->assertDontSee('g-recaptcha', false);
});
it('schickt die Anfrage nur an mivita, nie an die eingegebene Adresse', function () {
$this->post('/kontakt', contactPayload())
->assertOk()
->assertSee(__('website.thank_you_for_your_request'));
Mail::assertSent(MailContact::class, 1);
Mail::assertSent(MailContact::class, function (MailContact $mail) {
return $mail->hasTo('kontakt@mivita.test')
&& ! $mail->hasTo('anna@example.com')
&& ! $mail->hasBcc('anna@example.com')
&& $mail->hasReplyTo('anna@example.com', 'Anna Müller');
});
Http::assertSent(fn ($request) => $request['secret'] === 'secret-key' && $request['response'] === 'token');
});
it('schickt die Anfrage aus einem Berater-Shop zusätzlich an den Berater', function () {
$consultant = User::forceCreate([
'email' => 'berater@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$userShop = UserShop::create([
'user_id' => $consultant->id,
'name' => 'Freshzone',
'slug' => 'freshzone',
'active' => true,
]);
$this->withoutMiddleware(SubdomainResolver::class)
->withSession(['user_shop' => $userShop])
->post('/kontakt', contactPayload())
->assertOk();
Mail::assertSent(MailContact::class, function (MailContact $mail) {
return $mail->hasTo('kontakt@mivita.test')
&& $mail->hasBcc('berater@example.com')
&& ! $mail->hasTo('anna@example.com')
&& str_contains($mail->subject, 'freshzone.mivita.care');
});
});
it('lehnt die Spam-Anfrage aus dem Beispiel ab', function () {
$spam = 'Приветствуем! Напоминаем, что на вашем бонусном счёте находится 926USD. '
."Воспользоваться бонусом: https://share.google/O8hzS8W7vPuroPXdL\nВсего доброго, команда сайта";
$this->from('/kontakt')
->post('/kontakt', contactPayload([
'first_name' => $spam,
'last_name' => $spam,
'email' => 'igor.makhataev@icloud.com',
'phone' => '88538273161',
'message' => $spam,
]))
->assertRedirect('/kontakt')
->assertSessionHasErrors(['first_name', 'last_name']);
Mail::assertNothingSent();
});
it('lehnt Namen mit nicht-lateinischer Schrift, Links oder Ziffern ab', function (string $name) {
$this->post('/kontakt', contactPayload(['first_name' => $name]))
->assertSessionHasErrors('first_name');
Mail::assertNothingSent();
})->with([
'kyrillisch' => 'Игорь',
'link' => 'https://share.google/abc',
'ziffern' => 'Anna123',
'zu lang' => str_repeat('a', 51),
]);
it('akzeptiert Namen mit Umlauten, Akzenten, Bindestrich und Apostroph', function (string $name) {
$this->post('/kontakt', contactPayload(['last_name' => $name]))
->assertSessionHasNoErrors();
Mail::assertSent(MailContact::class);
})->with(['Müller-Lüdenscheidt', 'García Nuñez', "O'Connor", 'Lefèvre', 'Dr. Weiß']);
it('lehnt Nachrichten mit zu vielen Links ab', function () {
$this->post('/kontakt', contactPayload([
'message' => 'Seht mal: https://a.example https://b.example www.c.example',
]))->assertSessionHasErrors('message');
Mail::assertNothingSent();
});
it('lehnt ungültige Telefonnummern ab', function () {
$this->post('/kontakt', contactPayload(['phone' => 'ruf mich an']))
->assertSessionHasErrors('phone');
});
it('lehnt die Anfrage ab, wenn Turnstile das Token nicht bestätigt', function () {
$this->turnstileResponse = fn () => Http::response(['success' => false, 'error-codes' => ['invalid-input-response']]);
$this->post('/kontakt', contactPayload())
->assertSessionHasErrors('cf-turnstile-response');
Mail::assertNothingSent();
});
it('lehnt die Anfrage ohne Turnstile-Token ab', function () {
$this->post('/kontakt', contactPayload(['cf-turnstile-response' => '']))
->assertSessionHasErrors('cf-turnstile-response');
Http::assertNothingSent();
Mail::assertNothingSent();
});
it('verwirft Bots still und zeigt trotzdem die Erfolgsseite', function (array $overrides, string $reason) {
Log::spy();
Log::shouldReceive('channel')->andReturnSelf();
$this->post('/kontakt', contactPayload($overrides))
->assertOk()
->assertSee(__('website.thank_you_for_your_request'));
Mail::assertNothingSent();
Log::shouldHaveReceived('channel')->with('form_spam');
Log::shouldHaveReceived('info')->withArgs(fn ($message, $context = []) => ($context['reason'] ?? null) === $reason);
})->with([
'Honeypot ausgefüllt' => [fn () => [FormSpamGuard::HONEYPOT_FIELD => 'https://spam.example'], 'honeypot'],
'zu schnell abgeschickt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp)], 'too_fast'],
'Zeitstempel fehlt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => ''], 'invalid_form_token'],
'Zeitstempel manipuliert' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => '1700000000'], 'invalid_form_token'],
]);
it('begrenzt die Anzahl der Anfragen pro IP', function () {
foreach (range(1, 5) as $i) {
$this->post('/kontakt', contactPayload(['email' => "anna{$i}@example.com"]))->assertOk();
}
$this->from('/kontakt')
->post('/kontakt', contactPayload(['email' => 'anna6@example.com']))
->assertRedirect('/kontakt')
->assertSessionHasErrors(['email' => __('form_protection.too_many_requests')]);
Mail::assertSent(MailContact::class, 5);
});
it('begrenzt die Anzahl der Anfragen pro E-Mail-Adresse', function () {
foreach (range(1, 5) as $i) {
$this->withServerVariables(['REMOTE_ADDR' => "10.0.0.{$i}"])
->post('/kontakt', contactPayload())->assertOk();
}
$this->withServerVariables(['REMOTE_ADDR' => '10.0.0.99'])
->post('/kontakt', contactPayload(['email' => 'ANNA@example.com']))
->assertSessionHasErrors(['email' => __('form_protection.too_many_requests')]);
Mail::assertSent(MailContact::class, 5);
});
it('gibt Eingaben in der Mail escaped aus', function () {
$mail = new MailContact([
'salutation' => 'ms',
'first_name' => 'Anna',
'last_name' => 'Müller',
'email' => 'anna@example.com',
'phone' => null,
'message' => '<a href="https://evil.example">Klick</a>',
]);
$html = $mail->render();
expect($html)
->toContain('&lt;a href=&quot;https://evil.example&quot;&gt;Klick&lt;/a&gt;')
->not->toContain('<a href="https://evil.example">');
});
it('wertet Turnstile als fehlgeschlagen, wenn Cloudflare nicht erreichbar ist', function () {
$this->turnstileResponse = fn () => throw new ConnectionException('timeout');
expect(app(TurnstileVerifier::class)->verify('token', '127.0.0.1'))->toBeFalse();
});
it('wertet Turnstile als fehlgeschlagen, wenn kein Secret konfiguriert ist', function () {
config(['services.turnstile.secret_key' => null]);
expect(app(TurnstileVerifier::class)->verify('token'))->toBeFalse();
Http::assertNothingSent();
});

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Tests\TestCase;
uses(TestCase::class);
/**
* Registrierung, Wizard, Erst-Passwort, Passwort ändern und Zurücksetzen nutzen alle Password::defaults().
*/
it('verlangt systemweit mindestens 8 Zeichen für neue Passwörter', function (string $password, bool $passes) {
$validator = Validator::make(
['password' => $password, 'password_confirmation' => $password],
['password' => ['required', 'string', 'confirmed', Password::defaults()]],
);
expect($validator->passes())->toBe($passes);
})->with([
'6 Zeichen' => ['abc123', false],
'7 Zeichen' => ['abcd123', false],
'8 Zeichen' => ['abcd1234', true],
'20 Zeichen' => [str_repeat('x', 20), true],
]);
it('nennt in der Fehlermeldung die Mindestlänge', function () {
app()->setLocale('de');
$validator = Validator::make(
['password' => 'kurz', 'password_confirmation' => 'kurz'],
['password' => ['required', 'confirmed', Password::defaults()]],
);
expect($validator->errors()->first('password'))->toContain('8');
});

View file

@ -0,0 +1,179 @@
<?php
use App\Mail\MailVerifyAccount;
use App\Services\FormSpamGuard;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
config([
'services.turnstile.site_key' => 'site-key',
'services.turnstile.secret_key' => 'secret-key',
]);
Mail::fake();
$this->turnstileResponse = fn () => Http::response(['success' => true]);
Http::fake(['challenges.cloudflare.com/*' => fn () => ($this->turnstileResponse)()]);
});
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function registerPayload(array $overrides = []): array
{
return array_merge([
'salutation' => 'ms',
'first_name' => 'Anna',
'last_name' => 'Müller',
'email' => 'anna@example.com',
'email-confirm' => 'anna@example.com',
'password' => 'geheim123',
'password_confirmation' => 'geheim123',
'accepted_data_protection' => '1',
'cf-turnstile-response' => 'token',
FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp - 10),
FormSpamGuard::HONEYPOT_FIELD => '',
], $overrides);
}
function makeSponsor(array $attributes = []): User
{
return User::forceCreate(array_merge([
'email' => 'sponsor-'.uniqid().'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'active' => 1,
'blocked' => 0,
'payment_account' => now()->addYear(),
], $attributes));
}
function memberNumber(User $user): string
{
return 'm'.($user->id + (int) config('mivita.add_number_id'));
}
it('zeigt das Registrierungsformular mit Turnstile statt reCAPTCHA', function () {
$this->get('/registrierung')
->assertOk()
->assertSee('cf-turnstile', false)
->assertSee('data-sitekey="site-key"', false)
->assertSee(FormSpamGuard::HONEYPOT_FIELD, false)
->assertDontSee('g-recaptcha', false);
});
it('legt bei gültiger Registrierung den Benutzer an und verschickt die Bestätigungsmail', function () {
$this->post('/registrierung', registerPayload())
->assertRedirect('/registrierung/finish');
$user = User::where('email', 'anna@example.com')->first();
expect($user)->not->toBeNull()
->and($user->account->first_name)->toBe('Anna')
->and($user->m_sponsor)->toBe(1);
Mail::assertSent(MailVerifyAccount::class, fn ($mail) => $mail->hasTo('anna@example.com'));
});
it('übernimmt einen aktiven Sponsor aus der Partner-Nummer', function () {
$sponsor = makeSponsor();
$this->post('/registrierung', registerPayload(['from_member_id' => memberNumber($sponsor)]))
->assertRedirect('/registrierung/finish');
expect(User::where('email', 'anna@example.com')->first()->m_sponsor)->toBe($sponsor->id);
});
it('ignoriert manipulierte Partner-Nummern von inaktiven oder unbekannten Beratern', function (Closure $memberId) {
$this->post('/registrierung', registerPayload(['from_member_id' => $memberId()]))
->assertRedirect('/registrierung/finish');
expect(User::where('email', 'anna@example.com')->first()->m_sponsor)->toBe(1);
})->with([
'gesperrt' => [fn () => memberNumber(makeSponsor(['blocked' => 1]))],
'Account abgelaufen' => [fn () => memberNumber(makeSponsor(['payment_account' => now()->subDay()]))],
'unbekannt' => [fn () => 'm999999'],
]);
it('lehnt Werbetexte, Links und fremde Schrift in Namensfeldern ab', function (string $name) {
$this->post('/registrierung', registerPayload(['first_name' => $name]))
->assertSessionHasErrors('first_name');
expect(User::where('email', 'anna@example.com')->exists())->toBeFalse();
Mail::assertNothingSent();
})->with([
'kyrillisch' => 'Приветствуем! Бонус 926USD',
'link' => 'Bonus https://share.google/abc',
'zu lang' => str_repeat('a', 51),
]);
it('lehnt die Registrierung ab, wenn Turnstile das Token nicht bestätigt', function () {
$this->turnstileResponse = fn () => Http::response(['success' => false]);
$this->post('/registrierung', registerPayload())
->assertSessionHasErrors('cf-turnstile-response');
expect(User::where('email', 'anna@example.com')->exists())->toBeFalse();
});
it('verlangt ein Passwort mit mindestens 8 Zeichen', function () {
$this->post('/registrierung', registerPayload(['password' => 'kurz123', 'password_confirmation' => 'kurz123']))
->assertSessionHasErrors('password');
expect(User::where('email', 'anna@example.com')->exists())->toBeFalse();
$this->post('/registrierung', registerPayload(['password' => 'genau8zk', 'password_confirmation' => 'genau8zk']))
->assertSessionHasNoErrors();
expect(User::where('email', 'anna@example.com')->exists())->toBeTrue();
});
it('gibt das Passwort nach Fehlern nicht ins Formular zurück', function () {
$this->post('/registrierung', registerPayload(['first_name' => 'Игорь']))
->assertSessionHasErrors('first_name')
->assertSessionMissing('_old_input.password');
});
it('verwirft Bots still und legt keinen Benutzer an', function (Closure $overrides, string $reason) {
Log::spy();
Log::shouldReceive('channel')->andReturnSelf();
$this->post('/registrierung', registerPayload($overrides()))
->assertRedirect('/registrierung/finish');
expect(User::where('email', 'anna@example.com')->exists())->toBeFalse();
Mail::assertNothingSent();
Log::shouldHaveReceived('channel')->with('form_spam');
Log::shouldHaveReceived('info')->withArgs(
fn ($message, $context = []) => ($context['form'] ?? null) === 'register' && ($context['reason'] ?? null) === $reason
);
})->with([
'Honeypot ausgefüllt' => [fn () => [FormSpamGuard::HONEYPOT_FIELD => 'x'], 'honeypot'],
'zu schnell abgeschickt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => encrypt(now()->timestamp)], 'too_fast'],
'Zeitstempel fehlt' => [fn () => [FormSpamGuard::TIMESTAMP_FIELD => ''], 'invalid_form_token'],
]);
it('begrenzt die Anzahl der Registrierungen pro IP', function () {
foreach (range(1, 5) as $i) {
$this->post('/registrierung', registerPayload([
'email' => "anna{$i}@example.com",
'email-confirm' => "anna{$i}@example.com",
]))->assertRedirect('/registrierung/finish');
}
$this->from('/registrierung')
->post('/registrierung', registerPayload([
'email' => 'anna6@example.com',
'email-confirm' => 'anna6@example.com',
]))
->assertRedirect('/registrierung')
->assertSessionHasErrors(['email' => __('form_protection.too_many_requests')])
->assertSessionMissing('_old_input.password');
expect(User::where('email', 'anna6@example.com')->exists())->toBeFalse();
});