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

@ -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();