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 9f806de1af
commit 05535aac37
9 changed files with 303 additions and 21 deletions

View file

@ -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

View file

@ -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.

View file

@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Database\Factories\FailedLoginTallyFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
/**
* Tageszähler für Anmeldeversuche mit unbekannter Adresse, je Einstieg
* (`livewire`, `fortify`, …) und Quelle (gekürzter IP-HMAC). Ersetzt die
* Einzeleinträge im Aktivitäts-Verlauf (Monitoring-Plan, Nachtrag 1.7).
*/
class FailedLoginTally extends Model
{
/** @use HasFactory<FailedLoginTallyFactory> */
use HasFactory;
use MassPrunable;
public $timestamps = false;
protected $fillable = ['day', 'entry', 'ip_hash', 'attempts', 'first_at', 'last_at'];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'day' => 'date',
'attempts' => 'integer',
'first_at' => 'datetime',
'last_at' => 'datetime',
];
}
/**
* @return Builder<static>
*/
public function prunable(): Builder
{
return static::query()->whereDate('day', '<', now()->subDays((int) config('monitoring.failed_logins.retention_days', 90))->toDateString());
}
}

View file

@ -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<string, mixed> $queue
* @return list<array{key: string, label: string, status: string, detail: string}>
@ -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();

View file

@ -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,

View file

@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\FailedLoginTally;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<FailedLoginTally>
*/
class FailedLoginTallyFactory extends Factory
{
/**
* @return array<string, mixed>
*/
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,
];
}
}

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Zähler für Anmeldeversuche mit unbekannter Adresse (Monitoring-Plan 1.7,
* Nachtrag 06.10.2026). Seit dem Go-Live der Protokollierung kamen rund
* 250 solcher Versuche am Tag aus über 50 Quellen — verteiltes Durchprobieren
* fremder Zugangsdaten. Einzeln im Aktivitäts-Verlauf verdeckten sie die
* relevanten Einträge; hier eine Zeile je Tag, Einstieg und Quelle.
*
* `ip_hash` ist der gekürzte HMAC wie im Verlauf (nie die IP), leer statt
* NULL, damit der eindeutige Schlüssel greift. Löschfrist über
* `model:prune` (config monitoring.failed_logins.retention_days).
* Neue, kleine Tabelle — läuft sofort.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('failed_login_tallies', function (Blueprint $table) {
$table->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');
}
};

View file

@ -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'])

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');
});