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>
This commit is contained in:
Kevin Adametz 2026-10-06 15:30:29 +00:00
parent bc5e151823
commit 0aaee12eb5
9 changed files with 303 additions and 21 deletions

View file

@ -2,13 +2,16 @@
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 und User-Bezug, ohne Passwort und ohne Klartext-IP.
* 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)
{
@ -28,24 +31,65 @@ test('a wrong password is recorded with the account and the reason', function ()
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 told apart from an unknown address', function () {
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'] as $email) {
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');
}
$events = failedLoginEvents(ActivityType::LoginFailed);
$tally = FailedLoginTally::query()->sole();
expect($events->pluck('meta.reason')->all())->toBe(['no_password', 'unknown_account'])
->and($events[1]->user_id)->toBeNull()
->and($events[1]->description)->not->toContain('niemand@example.com');
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 () {
@ -58,6 +102,7 @@ test('a lockout is recorded for the targeted account', function () {
$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);
});
@ -72,5 +117,8 @@ test('a wrong two-factor code is recorded', function () {
$this->withSession(['login.id' => $user->id, 'login.remember' => false])
->post('/two-factor-challenge', ['code' => '000000']);
expect(failedLoginEvents(ActivityType::TwoFactorFailed)->sole()->user_id)->toBe($user->id);
$event = failedLoginEvents(ActivityType::TwoFactorFailed)->sole();
expect($event->user_id)->toBe($user->id)
->and($event->meta['entry'])->toBe('two-factor');
});