feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile
- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier, <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert - Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden); Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen - Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach - Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true) - contact-messages:quarantine-spam für den Altbestand - CSP erlaubt challenges.cloudflare.com, Log-Kanal spam - Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
61f449f483
commit
06d2889a4d
40 changed files with 1992 additions and 40 deletions
89
tests/Feature/Auth/PruneBotRegistrationsTest.php
Normal file
89
tests/Feature/Auth/PruneBotRegistrationsTest.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Tests\TestCase;
|
||||
|
||||
/*
|
||||
* users:prune-bot-registrations (Spamschutz B4): nur Formular-Konten, die
|
||||
* nie bestätigt und nie genutzt wurden — Altkonten bleiben unberührt.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function botRegistration(array $attributes = []): User
|
||||
{
|
||||
return User::factory()->unverified()->create([
|
||||
'name' => 'ruLrlotwaCoVStmZetSxD',
|
||||
'is_active' => false,
|
||||
'legacy_id' => null,
|
||||
'last_login_at' => null,
|
||||
'created_at' => now()->subDays(20),
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
test('unused unconfirmed registrations older than the deadline are deleted', function () {
|
||||
/** @var TestCase $this */
|
||||
$bot = botRegistration();
|
||||
|
||||
$this->artisan('users:prune-bot-registrations')
|
||||
->expectsOutputToContain('Bot-Registrierungen gelöscht: 1')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(User::withTrashed()->find($bot->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('accounts that are recent, legacy, confirmed, used or owned are kept', function () {
|
||||
/** @var TestCase $this */
|
||||
$kept = [
|
||||
botRegistration(['created_at' => now()->subDays(5)]),
|
||||
botRegistration(['legacy_id' => 4711]),
|
||||
botRegistration(['email_verified_at' => now()]),
|
||||
botRegistration(['last_login_at' => now()->subDays(3)]),
|
||||
botRegistration(['oauth_provider' => 'google']),
|
||||
botRegistration(['is_active' => true]),
|
||||
];
|
||||
$withCompany = botRegistration();
|
||||
$withCompany->companies()->attach(Company::factory()->create(), ['role' => 'owner', 'granted_via' => 'manual']);
|
||||
$kept[] = $withCompany;
|
||||
|
||||
$this->artisan('users:prune-bot-registrations')->assertSuccessful();
|
||||
|
||||
expect(User::query()->whereKey(collect($kept)->pluck('id'))->count())->toBe(count($kept));
|
||||
});
|
||||
|
||||
test('the dry run lists candidates and deletes nothing', function () {
|
||||
/** @var TestCase $this */
|
||||
$bot = botRegistration(['email' => 'opfer@example.test']);
|
||||
|
||||
$this->artisan('users:prune-bot-registrations', ['--dry-run' => true])
|
||||
->expectsOutputToContain('op***@example.test')
|
||||
->expectsOutputToContain('Probelauf: 1 Konten älter als 14 Tage würden gelöscht, davon 1 mit Zufallsnamen.')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(User::query()->find($bot->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a deadline below seven days is refused', function () {
|
||||
/** @var TestCase $this */
|
||||
botRegistration(['created_at' => now()->subDays(3)]);
|
||||
|
||||
$this->artisan('users:prune-bot-registrations', ['--days' => 2])->assertFailed();
|
||||
|
||||
expect(User::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('the daily run stays off until it is switched on', function () {
|
||||
$event = collect(app(Schedule::class)->events())
|
||||
->first(fn ($event) => str_contains((string) $event->command, 'users:prune-bot-registrations'));
|
||||
|
||||
expect($event)->not->toBeNull()
|
||||
->and($event->filtersPass(app()))->toBeFalse();
|
||||
|
||||
config()->set('auth.prune_bot_registrations.enabled', true);
|
||||
|
||||
expect($event->filtersPass(app()))->toBeTrue();
|
||||
});
|
||||
132
tests/Feature/Auth/RegistrationSpamProtectionTest.php
Normal file
132
tests/Feature/Auth/RegistrationSpamProtectionTest.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/*
|
||||
* Spamschutz der Registrierung (Spamschutz B4): 389 Bot-Konten in 30 Tagen
|
||||
* bis 07.10.2026, jedes mit einer Bestätigungsmail an eine fremde Adresse.
|
||||
*/
|
||||
|
||||
function enableRegistrationTurnstile(): void
|
||||
{
|
||||
config()->set('services.turnstile.site_key', '0x4AAAAAAAsitekey');
|
||||
config()->set('services.turnstile.secret_key', '0x4AAAAAAAsecretkeyforthetests');
|
||||
config()->set('services.turnstile.fail_open', true);
|
||||
}
|
||||
|
||||
function registrationForm(string $name = 'Paula Presse'): Testable
|
||||
{
|
||||
return Livewire::test('auth.register')
|
||||
->set('name', $name)
|
||||
->set('email', 'paula@example.com')
|
||||
->set('password', 'sicheres-passwort-2026')
|
||||
->set('password_confirmation', 'sicheres-passwort-2026')
|
||||
->set('terms_accepted', true);
|
||||
}
|
||||
|
||||
test('registration with a confirmed turnstile token creates the account', function () {
|
||||
/** @var TestCase $this */
|
||||
Notification::fake();
|
||||
enableRegistrationTurnstile();
|
||||
Http::fake(['challenges.cloudflare.com/*' => Http::response(['success' => true, 'action' => 'register'])]);
|
||||
|
||||
registrationForm()
|
||||
->set('turnstileToken', 'token-aus-dem-widget')
|
||||
->call('register')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('verification.notice', absolute: false));
|
||||
|
||||
expect(User::query()->where('email', 'paula@example.com')->exists())->toBeTrue()
|
||||
->and(ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('registration without a confirmed token creates no account and sends no mail', function (string $token, array $siteverify) {
|
||||
/** @var TestCase $this */
|
||||
Notification::fake();
|
||||
enableRegistrationTurnstile();
|
||||
Http::fake(['challenges.cloudflare.com/*' => Http::response($siteverify)]);
|
||||
|
||||
registrationForm()
|
||||
->set('turnstileToken', $token)
|
||||
->call('register')
|
||||
->assertHasErrors('turnstile')
|
||||
->assertDispatched('turnstile-reset')
|
||||
->assertSet('turnstileToken', '');
|
||||
|
||||
expect(User::query()->where('email', 'paula@example.com')->exists())->toBeFalse();
|
||||
Notification::assertNothingSent();
|
||||
})->with([
|
||||
'no token' => ['', ['success' => true, 'action' => 'register']],
|
||||
'rejected token' => ['token', ['success' => false, 'error-codes' => ['invalid-input-response']]],
|
||||
'token for the contact form' => ['token', ['success' => true, 'action' => 'contact']],
|
||||
]);
|
||||
|
||||
test('if cloudflare is unreachable the account is created and marked for review', function () {
|
||||
/** @var TestCase $this */
|
||||
Notification::fake();
|
||||
enableRegistrationTurnstile();
|
||||
Http::fake(['challenges.cloudflare.com/*' => Http::failedConnection()]);
|
||||
|
||||
registrationForm()
|
||||
->set('turnstileToken', 'token-aus-dem-widget')
|
||||
->call('register')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$user = User::query()->where('email', 'paula@example.com')->sole();
|
||||
$event = ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->sole();
|
||||
|
||||
expect($event->user_id)->toBe($user->id)
|
||||
->and($event->meta)->toMatchArray(['form' => 'register', 'reason' => 'siteverify_unreachable']);
|
||||
});
|
||||
|
||||
test('if the widget could not load in the browser the account is created and marked', function () {
|
||||
/** @var TestCase $this */
|
||||
Notification::fake();
|
||||
enableRegistrationTurnstile();
|
||||
Http::fake();
|
||||
|
||||
registrationForm()
|
||||
->set('turnstileUnavailable', true)
|
||||
->call('register')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Http::assertNothingSent();
|
||||
expect(ActivityEvent::query()->where('type', ActivityType::BotCheckUnavailable->value)->sole()->meta['reason'])
|
||||
->toBe('widget_not_loaded');
|
||||
});
|
||||
|
||||
test('random bot names are rejected', function (string $name) {
|
||||
/** @var TestCase $this */
|
||||
Notification::fake();
|
||||
|
||||
registrationForm($name)->call('register')->assertHasErrors('name');
|
||||
|
||||
expect(User::query()->count())->toBe(0);
|
||||
})->with(['ruLrlotwaCoVStmZetSxD', 'VJxlVxkqQzgMfJPaM', 'GeCWsvfRZNHeNXubB']);
|
||||
|
||||
test('real names pass the name check', function (string $name) {
|
||||
/** @var TestCase $this */
|
||||
Notification::fake();
|
||||
|
||||
registrationForm($name)->call('register')->assertHasNoErrors();
|
||||
})->with(['Paula Presse', 'Anna-Lena Müller-Lüdenscheidt', 'Dr. Jabena B. McNair', 'MacGregor', 'Sky']);
|
||||
|
||||
test('the registration page renders the widget only with keys', function () {
|
||||
/** @var TestCase $this */
|
||||
// Ohne Keys kein Widget (der Livewire-Snapshot nennt die Properties trotzdem).
|
||||
$this->get('/register')->assertSuccessful()->assertDontSee('pkTurnstileReady', false);
|
||||
|
||||
enableRegistrationTurnstile();
|
||||
|
||||
$this->get('/register')
|
||||
->assertSuccessful()
|
||||
->assertSee('0x4AAAAAAAsitekey', false)
|
||||
->assertSee('pkTurnstileReady', false);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue