diff --git a/.env.example b/.env.example index aded6a0f..e5f3e016 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,10 @@ MONITORING_NTFY_PRIORITY=4 # Tiefer Health-Check /health für den externen Monitor (Bearer oder ?token=). # Ohne Token gibt es den Endpunkt nicht. MONITORING_HEALTH_TOKEN= +# Anmeldeversuche mit unbekannter Adresse: Tageszähler statt Verlauf. +# Löschfrist in Tagen und Warnschwelle je Tag im Systemstatus (Standardwerte). +MONITORING_FAILED_LOGIN_RETENTION_DAYS=90 +MONITORING_FAILED_LOGIN_WARN_PER_DAY=1000 # Fehler an GlitchTip (selbst gehostet, Sentry-Protokoll; Monitoring-Plan # 1.1/1.2). Ohne DSN passiert nichts. SENTRY_RELEASE bei jedem Deploy auf diff --git a/app/Listeners/RecordAuthActivity.php b/app/Listeners/RecordAuthActivity.php index f407b023..2766c3ff 100644 --- a/app/Listeners/RecordAuthActivity.php +++ b/app/Listeners/RecordAuthActivity.php @@ -5,6 +5,7 @@ namespace App\Listeners; use App\Enums\ActivityType; use App\Enums\ConversionEventType; use App\Enums\ConversionSource; +use App\Models\FailedLoginTally; use App\Models\User; use App\Services\ActivityLog; use App\Services\Auth\LoginCompletion; @@ -14,7 +15,10 @@ use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Events\Login; use Illuminate\Auth\Events\Registered; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Laravel\Fortify\Events\TwoFactorAuthenticationFailed; +use Throwable; /** * Protokolliert Registrierungen, Logins und gescheiterte Anmeldungen im @@ -34,7 +38,6 @@ class RecordAuthActivity public const AUTH_METHOD_ATTRIBUTE = 'auth_method'; private const FAILURE_LABELS = [ - 'unknown_account' => 'unbekannte Adresse', 'no_password' => 'Konto ohne Passwort (Magic Link/Google)', 'wrong_password' => 'falsches Passwort', ]; @@ -101,24 +104,31 @@ class RecordAuthActivity * Zugangsdaten wird nur die Adresse gelesen — nie das Passwort. Der * Grund unterscheidet Angriff/Tippfehler von einem Bedienproblem: Ein * Konto ohne Passwort heißt, der Kunde kennt den Magic-Link-Weg nicht. + * + * Unbekannte Adressen landen nicht im Verlauf, sondern im Tageszähler + * (FailedLoginTally): seit dem Go-Live rund 250 am Tag aus über 50 + * Quellen — verteiltes Durchprobieren fremder Zugangsdaten, das die + * relevanten Einträge verdeckte (Nachtrag 06.10.2026). */ public function recordFailedLogin(Failed $event): void { + $request = request(); $user = $event->user instanceof User ? $event->user : null; - $reason = match (true) { - $user === null => 'unknown_account', - $user->password === null => 'no_password', - default => 'wrong_password', - }; + + if ($user === null) { + $this->tallyUnknownAccount($request); + + return; + } + + $reason = $user->password === null ? 'no_password' : 'wrong_password'; $this->activityLog->record( ActivityType::LoginFailed, - $user !== null - ? sprintf('Anmeldung für %s (%s) fehlgeschlagen — %s.', $user->name, $user->email, self::FAILURE_LABELS[$reason]) - : 'Anmeldung mit unbekannter Adresse fehlgeschlagen.', + sprintf('Anmeldung für %s (%s) fehlgeschlagen — %s.', $user->name, $user->email, self::FAILURE_LABELS[$reason]), user: $user, - meta: ['reason' => $reason, 'ip_hash' => self::ipHash(request())], - actorLabel: $user->name ?? 'unbekannt', + meta: ['reason' => $reason, 'entry' => self::entryOf($request), 'ip_hash' => self::ipHash($request)], + actorLabel: $user->name, ); } @@ -133,7 +143,7 @@ class RecordAuthActivity ? sprintf('Anmeldung für %s (%s) nach zu vielen Fehlversuchen gesperrt.', $user->name, $user->email) : 'Anmeldung nach zu vielen Fehlversuchen gesperrt (unbekannte Adresse).', user: $user, - meta: ['ip_hash' => self::ipHash($event->request)], + meta: ['entry' => self::entryOf($event->request), 'ip_hash' => self::ipHash($event->request)], actorLabel: $user->name ?? 'unbekannt', ); } @@ -146,10 +156,54 @@ class RecordAuthActivity ActivityType::TwoFactorFailed, sprintf('%s (%s) hat einen falschen 2FA- bzw. Wiederherstellungs-Code eingegeben.', $user->name, $user->email), user: $user, - meta: ['ip_hash' => self::ipHash(request())], + meta: ['entry' => self::entryOf(request()), 'ip_hash' => self::ipHash(request())], ); } + /** + * Über welchen Weg der Versuch kam: das Login-Formular (Livewire), + * Fortifys `POST /login` (antwortet auf allen Domains, auch als JSON) + * oder die 2FA-Abfrage. Grundlage für die Wahl der Gegenmaßnahme. + */ + public static function entryOf(Request $request): string + { + return match (true) { + $request->hasHeader('X-Livewire') => 'livewire', + $request->routeIs('login.store') => 'fortify', + $request->routeIs('two-factor.login.store') => 'two-factor', + default => 'other', + }; + } + + /** + * Ein Versuch mehr für heute, diesen Einstieg und diese Quelle. + * Best-Effort wie der Verlauf: ein Fehler hier darf den Login-Ablauf + * nicht stören. + */ + private function tallyUnknownAccount(Request $request): void + { + $now = now(); + + try { + FailedLoginTally::query()->upsert( + [[ + 'day' => $now->toDateString(), + 'entry' => self::entryOf($request), + 'ip_hash' => self::ipHash($request) ?? '', + 'attempts' => 1, + 'first_at' => $now, + 'last_at' => $now, + ]], + ['day', 'entry', 'ip_hash'], + ['attempts' => DB::raw('attempts + 1'), 'last_at' => $now], + ); + } catch (Throwable $exception) { + Log::warning('Zähler für fehlgeschlagene Anmeldungen konnte nicht geschrieben werden.', [ + 'error' => $exception->getMessage(), + ]); + } + } + /** * Gekürzter HMAC der IP (M9): erkennt „dieselbe Quelle“ über mehrere * Versuche, ohne die Adresse selbst zu speichern. diff --git a/app/Models/FailedLoginTally.php b/app/Models/FailedLoginTally.php new file mode 100644 index 00000000..d2fc79fb --- /dev/null +++ b/app/Models/FailedLoginTally.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + use MassPrunable; + + public $timestamps = false; + + protected $fillable = ['day', 'entry', 'ip_hash', 'attempts', 'first_at', 'last_at']; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'day' => 'date', + 'attempts' => 'integer', + 'first_at' => 'datetime', + 'last_at' => 'datetime', + ]; + } + + /** + * @return Builder + */ + public function prunable(): Builder + { + return static::query()->whereDate('day', '<', now()->subDays((int) config('monitoring.failed_logins.retention_days', 90))->toDateString()); + } +} diff --git a/app/Services/Admin/AdminSystemHealth.php b/app/Services/Admin/AdminSystemHealth.php index 684b81a1..15b9cab5 100644 --- a/app/Services/Admin/AdminSystemHealth.php +++ b/app/Services/Admin/AdminSystemHealth.php @@ -4,6 +4,7 @@ namespace App\Services\Admin; use App\Authorization\Permission as PermissionCatalog; use App\Models\ArchiveEntry; +use App\Models\FailedLoginTally; use App\Models\PressReleaseAttachment; use App\Models\PressReleaseImage; use App\Services\Archive\ArchivePendingQueue; @@ -145,6 +146,35 @@ class AdminSystemHealth ]; } + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + private function failedLoginCheck(): array + { + $summary = fn (string $day): object => FailedLoginTally::query() + ->whereDate('day', $day) + ->selectRaw('coalesce(sum(attempts), 0) as attempts, count(distinct ip_hash) as sources') + ->first() ?? (object) ['attempts' => 0, 'sources' => 0]; + + $today = $summary(now()->toDateString()); + $yesterday = $summary(now()->subDay()->toDateString()); + $warnPerDay = (int) config('monitoring.failed_logins.warn_per_day', 1000); + + return [ + 'key' => 'failed_logins', + 'label' => 'Anmeldeversuche mit unbekannter Adresse', + 'status' => (int) $today->attempts >= $warnPerDay ? self::StatusWarn : self::StatusOk, + 'detail' => sprintf( + 'Heute %d Versuche aus %d Quellen, gestern %d aus %d.%s', + (int) $today->attempts, + (int) $today->sources, + (int) $yesterday->attempts, + (int) $yesterday->sources, + (int) $today->attempts >= $warnPerDay ? ' Ungewöhnlich viele — Einstieg in failed_login_tallies prüfen.' : '', + ), + ]; + } + /** * @param array $queue * @return list @@ -167,6 +197,11 @@ class AdminSystemHealth $checks[] = $probe->scheduler(); $checks[] = $probe->disk(); + // Anmeldeversuche mit unbekannter Adresse (Nachtrag 1.7): Tageszähler + // statt Verlaufseinträgen. Viel Grundrauschen ist normal; warnen erst + // ab der konfigurierten Tagesmenge. + $checks[] = $this->failedLoginCheck(); + // Neue Permissions ausgerollt? $expected = PermissionCatalog::adminOnly(); $existing = Permission::query()->whereIn('name', $expected)->pluck('name')->all(); diff --git a/config/monitoring.php b/config/monitoring.php index cb928137..4d72102b 100644 --- a/config/monitoring.php +++ b/config/monitoring.php @@ -66,6 +66,22 @@ return [ 'disk_min_free_percent' => (float) env('MONITORING_DISK_MIN_FREE_PERCENT', 10), ], + /* + |-------------------------------------------------------------------------- + | Fehlgeschlagene Anmeldungen mit unbekannter Adresse (Nachtrag 1.7) + |-------------------------------------------------------------------------- + | + | Werden nicht einzeln im Aktivitäts-Verlauf protokolliert, sondern je Tag, + | Einstieg und Quelle gezählt (failed_login_tallies). Der Systemstatus + | warnt ab `warn_per_day` Versuchen am Tag. + | + */ + + 'failed_logins' => [ + 'retention_days' => (int) env('MONITORING_FAILED_LOGIN_RETENTION_DAYS', 90), + 'warn_per_day' => (int) env('MONITORING_FAILED_LOGIN_WARN_PER_DAY', 1000), + ], + 'scheduler' => [ 'heartbeat_warn_minutes' => 3, 'heartbeat_critical_minutes' => 10, diff --git a/database/factories/FailedLoginTallyFactory.php b/database/factories/FailedLoginTallyFactory.php new file mode 100644 index 00000000..44f41a14 --- /dev/null +++ b/database/factories/FailedLoginTallyFactory.php @@ -0,0 +1,29 @@ + + */ +class FailedLoginTallyFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $at = now()->subMinutes(fake()->numberBetween(1, 600)); + + return [ + 'day' => $at->toDateString(), + 'entry' => fake()->randomElement(['livewire', 'fortify']), + 'ip_hash' => substr(hash('sha256', fake()->ipv4()), 0, 12), + 'attempts' => fake()->numberBetween(1, 10), + 'first_at' => $at, + 'last_at' => $at, + ]; + } +} diff --git a/database/migrations/2026_10_06_172532_create_failed_login_tallies_table.php b/database/migrations/2026_10_06_172532_create_failed_login_tallies_table.php new file mode 100644 index 00000000..244de438 --- /dev/null +++ b/database/migrations/2026_10_06_172532_create_failed_login_tallies_table.php @@ -0,0 +1,40 @@ +id(); + $table->date('day'); + $table->string('entry', 20); + $table->string('ip_hash', 12)->default(''); + $table->unsignedInteger('attempts')->default(0); + $table->timestamp('first_at')->nullable(); + $table->timestamp('last_at')->nullable(); + + $table->unique(['day', 'entry', 'ip_hash'], 'failed_login_tallies_day_entry_source_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('failed_login_tallies'); + } +}; diff --git a/routes/console.php b/routes/console.php index 9aa1079a..17482f7a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -20,6 +20,7 @@ use App\Console\Commands\PurgeRedactionSnapshots; use App\Console\Commands\RefreshMarketData; use App\Console\Commands\ResetMonthlyPressReleaseQuota; use App\Console\Commands\SweepOrphanPressReleaseOriginals; +use App\Models\FailedLoginTally; use App\Support\OperationalAlert; use Illuminate\Console\Scheduling\Event; use Illuminate\Foundation\Inspiring; @@ -245,6 +246,14 @@ Schedule::command(MonitoringHeartbeat::class) ->withoutOverlapping(2) ->onFailure($alertOnFailure('monitoring:heartbeat')); +// Tageszähler fehlgeschlagener Anmeldungen nach der Löschfrist entfernen +// (täglich 04:05, config monitoring.failed_logins.retention_days). +Schedule::command('model:prune', ['--model' => [FailedLoginTally::class]]) + ->dailyAt('04:05') + ->withoutOverlapping(30) + ->runInBackground() + ->onFailure($alertOnFailure('model:prune failed_login_tallies')); + // Fehlgeschlagene Jobs nach 30 Tagen entfernen (täglich 03:55). Bis dahin // sind sie im Systemstatus sichtbar und per `queue:retry` wiederholbar. Schedule::command('queue:prune-failed', ['--hours=720']) diff --git a/tests/Feature/Auth/FailedLoginActivityTest.php b/tests/Feature/Auth/FailedLoginActivityTest.php index b80ae8a9..c78452d8 100644 --- a/tests/Feature/Auth/FailedLoginActivityTest.php +++ b/tests/Feature/Auth/FailedLoginActivityTest.php @@ -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'); });