presseportale/tests/Feature/Auth/FailedLoginActivityTest.php
Kevin Adametz 05535aac37 auth: fehlgeschlagene Anmeldungen mit Einstieg (livewire/fortify/two-factor); unbekannte Adressen als Tageszähler statt Verlaufseintrag, Kachel im Systemstatus, Löschfrist 90 Tage
Auf Live kamen seit dem 05.10. rund 250 Versuche am Tag aus über 50 Quellen,
alle mit unbekannter Adresse — verteiltes Durchprobieren fremder
Zugangsdaten, das im Verlauf die relevanten Einträge verdeckte. Neue Tabelle
failed_login_tallies (Tag × Einstieg × IP-Hash), model:prune täglich 04:05.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:30:29 +00:00

124 lines
5.4 KiB
PHP

<?php
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use App\Models\FailedLoginTally;
use App\Models\User;
use App\Services\Admin\AdminSystemHealth;
use Livewire\Livewire;
use PragmaRX\Google2FA\Google2FA;
/**
* Gescheiterte Anmeldungen im Aktivitäts-Verlauf (Monitoring-Plan 1.7, M9):
* mit Grund, Einstieg und User-Bezug, ohne Passwort und ohne Klartext-IP.
* Unbekannte Adressen nur als Tageszähler (Nachtrag 06.10.2026).
*/
function failedLoginEvents(ActivityType $type)
{
return ActivityEvent::query()->where('type', $type->value)->orderBy('id')->get();
}
test('a wrong password is recorded with the account and the reason', function () {
$user = User::factory()->create(['email' => 'kunde@example.com']);
Livewire::test('auth.login')
->set('email', 'kunde@example.com')
->set('password', 'falsches-passwort')
->call('login')
->assertHasErrors('email');
$event = failedLoginEvents(ActivityType::LoginFailed)->sole();
expect($event->user_id)->toBe($user->id)
->and($event->meta['reason'])->toBe('wrong_password')
->and($event->meta['entry'])->toBe('livewire')
->and($event->meta['ip_hash'])->toHaveLength(12)
->and($event->description)->toContain('falsches Passwort')
->and(json_encode($event->toArray()))->not->toContain('falsches-passwort')
->and(json_encode($event->toArray()))->not->toContain('127.0.0.1');
});
test('an account without password is recorded, an unknown address only counted', function () {
User::factory()->create(['email' => 'legacy@example.com', 'password' => null]);
foreach (['legacy@example.com', 'niemand@example.com', 'auch-niemand@example.com'] as $email) {
Livewire::test('auth.login')->set('email', $email)->set('password', 'irgendwas')->call('login');
}
$tally = FailedLoginTally::query()->sole();
expect(failedLoginEvents(ActivityType::LoginFailed)->pluck('meta.reason')->all())->toBe(['no_password'])
->and($tally->day->toDateString())->toBe(now()->toDateString())
->and($tally->entry)->toBe('livewire')
->and($tally->ip_hash)->toHaveLength(12)
->and($tally->attempts)->toBe(2)
->and(json_encode($tally->toArray()))->not->toContain('niemand@example.com');
});
test('fortify post login is told apart from the livewire form', function () {
$user = User::factory()->create(['email' => 'kunde@example.com', 'is_active' => true]);
$this->post('/login', ['email' => 'unbekannt@example.com', 'password' => 'x']);
$this->post('/login', ['email' => 'kunde@example.com', 'password' => 'falsch']);
expect(FailedLoginTally::query()->sole()->entry)->toBe('fortify')
->and(failedLoginEvents(ActivityType::LoginFailed)->sole()->meta)->toMatchArray([
'reason' => 'wrong_password',
'entry' => 'fortify',
])
->and(failedLoginEvents(ActivityType::LoginFailed)->sole()->user_id)->toBe($user->id);
});
test('tallies are pruned after the retention period', function () {
config()->set('monitoring.failed_logins.retention_days', 90);
$old = FailedLoginTally::factory()->create(['day' => now()->subDays(91)->toDateString()]);
$recent = FailedLoginTally::factory()->create(['day' => now()->subDays(89)->toDateString()]);
$this->artisan('model:prune', ['--model' => [FailedLoginTally::class]])->assertSuccessful();
$this->assertModelMissing($old);
$this->assertModelExists($recent);
});
test('the system health shows unknown-address attempts and warns above the daily limit', function () {
config()->set('monitoring.failed_logins.warn_per_day', 10);
FailedLoginTally::factory()->create(['day' => now()->toDateString(), 'ip_hash' => 'aaaaaaaaaaaa', 'attempts' => 6]);
FailedLoginTally::factory()->create(['day' => now()->toDateString(), 'ip_hash' => 'bbbbbbbbbbbb', 'attempts' => 5]);
FailedLoginTally::factory()->create(['day' => now()->subDay()->toDateString(), 'ip_hash' => 'cccccccccccc', 'attempts' => 3]);
$check = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key')->get('failed_logins');
expect($check['status'])->toBe('warn')
->and($check['detail'])->toStartWith('Heute 11 Versuche aus 2 Quellen, gestern 3 aus 1.');
});
test('a lockout is recorded for the targeted account', function () {
$user = User::factory()->create(['email' => 'kunde@example.com']);
foreach (range(1, 6) as $attempt) {
Livewire::test('auth.login')->set('email', 'kunde@example.com')->set('password', 'falsch')->call('login');
}
$lockout = failedLoginEvents(ActivityType::LoginLockout)->sole();
expect($lockout->user_id)->toBe($user->id)
->and($lockout->meta['entry'])->toBe('livewire')
->and($lockout->description)->toContain('gesperrt')
->and(failedLoginEvents(ActivityType::LoginFailed))->toHaveCount(5);
});
test('a wrong two-factor code is recorded', function () {
$user = User::factory()->create(['is_active' => true]);
$user->forceFill([
'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()),
'two_factor_confirmed_at' => now(),
])->save();
$this->withSession(['login.id' => $user->id, 'login.remember' => false])
->post('/two-factor-challenge', ['code' => '000000']);
$event = failedLoginEvents(ActivityType::TwoFactorFailed)->sole();
expect($event->user_id)->toBe($user->id)
->and($event->meta['entry'])->toBe('two-factor');
});