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:
parent
fe1a9478f4
commit
4c944aa15d
44 changed files with 1320 additions and 248 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
$rules = [
|
||||
'old_password' => 'required|old_password:'.Auth::user()->password,
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
);
|
||||
'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(){
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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));
|
||||
if ($blockReason !== null) {
|
||||
FormSpamGuard::log($request, 'contact', $blockReason);
|
||||
} else {
|
||||
Mail::to($contact['email'])->bcc($contact_mail)->locale(\App::getLocale())->send(new MailContact($contact));
|
||||
$this->sendMail($request);
|
||||
}
|
||||
|
||||
$data = [
|
||||
return view('web.templates.contact-final', [
|
||||
'user_shop' => Util::getUserShop(),
|
||||
'mylangs' => Shop::getLangChange('webshop'),
|
||||
'yard_instance' => 'webshop',
|
||||
];
|
||||
return view('web.templates.contact-final', $data);
|
||||
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
private function reCaptcha_validate($attribute, $value, $parameters, $validator)
|
||||
private function sendMail(ContactStoreRequest $request): void
|
||||
{
|
||||
|
||||
$client = new Client();
|
||||
|
||||
$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;
|
||||
$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');
|
||||
}
|
||||
|
||||
|
||||
$mail = Mail::to(config('app.contact_mail'));
|
||||
if ($consultantEmail = Util::getUserShop()?->user?->email) {
|
||||
$mail->bcc($consultantEmail);
|
||||
}
|
||||
|
||||
$mail->locale(app()->getLocale())->send(new MailContact($contact));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
84
app/Http/Requests/Contact/ContactStoreRequest.php
Normal file
84
app/Http/Requests/Contact/ContactStoreRequest.php
Normal 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'));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
54
app/Http/Requests/Register/RegisterUserRequest.php
Normal file
54
app/Http/Requests/Register/RegisterUserRequest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +1,38 @@
|
|||
<?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') {
|
||||
$this->data['salutation'] = 'Herr';
|
||||
|
|
@ -42,18 +41,19 @@ class MailContact extends Mailable
|
|||
$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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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')]);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
29
app/Rules/PersonName.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/Rules/TurnstileToken.php
Normal file
23
app/Rules/TurnstileToken.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/Services/FormSpamGuard.php
Normal file
69
app/Services/FormSpamGuard.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
46
app/Services/TurnstileVerifier.php
Normal file
46
app/Services/TurnstileVerifier.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
233
dev/2026-10-09-spam/README.md
Normal file
233
dev/2026-10-09-spam/README.md
Normal 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 |
|
||||
24
dev/2026-10-09-spam/spam-mail.md
Normal file
24
dev/2026-10-09-spam/spam-mail.md
Normal 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
|
||||
10
resources/lang/de/contact.php
Normal file
10
resources/lang/de/contact.php
Normal 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.',
|
||||
];
|
||||
8
resources/lang/de/form_protection.php
Normal file
8
resources/lang/de/form_protection.php
Normal 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',
|
||||
];
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<?php
|
||||
|
||||
return array (
|
||||
'password' => 'Passwörter müssen mindestens 6 Zeichen lang sein und korrekt bestätigt 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.',
|
||||
);
|
||||
];
|
||||
|
|
|
|||
10
resources/lang/en/contact.php
Normal file
10
resources/lang/en/contact.php
Normal 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.',
|
||||
];
|
||||
8
resources/lang/en/form_protection.php
Normal file
8
resources/lang/en/form_protection.php
Normal 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',
|
||||
];
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<?php
|
||||
|
||||
return array (
|
||||
'password' => 'Passwords must be at least six characters and match the confirmation.',
|
||||
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.',
|
||||
);
|
||||
];
|
||||
|
|
|
|||
10
resources/lang/es/contact.php
Normal file
10
resources/lang/es/contact.php
Normal 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.',
|
||||
];
|
||||
8
resources/lang/es/form_protection.php
Normal file
8
resources/lang/es/form_protection.php
Normal 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',
|
||||
];
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<?php
|
||||
|
||||
return array (
|
||||
'password' => 'Las contraseñas deben tener al menos 6 caracteres y deben confirmarse correctamente.',
|
||||
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.',
|
||||
);
|
||||
];
|
||||
|
|
|
|||
10
resources/lang/fr/contact.php
Normal file
10
resources/lang/fr/contact.php
Normal 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.',
|
||||
];
|
||||
8
resources/lang/fr/form_protection.php
Normal file
8
resources/lang/fr/form_protection.php
Normal 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',
|
||||
];
|
||||
|
|
@ -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é.',
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@
|
|||
{{__('email.'.$key)}}:
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,10 +194,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="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
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
240
tests/Feature/ContactFormSpamTest.php
Normal file
240
tests/Feature/ContactFormSpamTest.php
Normal 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('<a href="https://evil.example">Klick</a>')
|
||||
->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();
|
||||
});
|
||||
35
tests/Feature/PasswordPolicyTest.php
Normal file
35
tests/Feature/PasswordPolicyTest.php
Normal 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');
|
||||
});
|
||||
179
tests/Feature/RegisterFormSpamTest.php
Normal file
179
tests/Feature/RegisterFormSpamTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue