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>
124 lines
5.4 KiB
PHP
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');
|
|
});
|