security: Andere Geräte abmelden, IP-weites Login-Limit, dauerhafte Sperrmarke, Passwortregeln, CSP im Melde-Modus

Restliche Phase 4 aus dem Sicherheitsaudit 07.10.2026:

- Sicherheitsseite: „Andere Geräte abmelden“ rotiert remember_token und
  beendet alle anderen Sessions, die aktuelle bleibt (nicht während einer
  Impersonation).
- LoginIpThrottle: 20 Fehlversuche je IP in 15 Minuten über alle Konten.
  Gezählt über das Failed-Event, damit Livewire-Login und Fortifys
  POST /login denselben Zähler teilen; Erfolg setzt ihn nicht zurück.
- Sperrmarke für Sessions in users.sessions_revoked_at_ms statt nur im
  Cache — übersteht cache:clear. Die alte Cache-Marke zählt übergangsweise
  mit und kann im nächsten Release entfallen.
- Neue und geänderte Passwörter: mindestens 12 Zeichen, in Produktion
  zusätzlich uncompromised() (HIBP, k-Anonymität). Bestehende bleiben gültig.
- Content-Security-Policy-Report-Only in Produktion; Meldungen über
  /csp-report nach storage/logs/csp-*.log, je Meldung höchstens einmal pro
  Stunde, ohne Query und tokenartige Pfadsegmente.

Migration: users.sessions_revoked_at_ms (users hat FULLTEXT, 25 MB, Sekunden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-08 13:02:49 +00:00
parent 9c4962fbe0
commit 5dfd622141
27 changed files with 601 additions and 39 deletions

View file

@ -2,6 +2,7 @@
use App\Models\User;
use App\Services\Auth\LoginCompletion;
use App\Services\Auth\LoginIpThrottle;
use App\Support\LoginRedirect;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Lockout;
@ -99,7 +100,12 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
*/
protected function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
// Zwei Grenzen: je Adresse und IP sowie IP-weit über alle Konten
// (LoginIpThrottle, Fehlversuche zählt CountFailedLoginsPerIp).
$ipThrottle = app(LoginIpThrottle::class);
$accountLimited = RateLimiter::tooManyAttempts($this->throttleKey(), 5);
if (! $accountLimited && ! $ipThrottle->isBlocked(request()->ip())) {
return;
}
@ -107,7 +113,9 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
// für den Aktivitäts-Verlauf mitgeben.
event(new Lockout(request()->duplicate(request: ['email' => $this->email])));
$seconds = RateLimiter::availableIn($this->throttleKey());
$seconds = $accountLimited
? RateLimiter::availableIn($this->throttleKey())
: $ipThrottle->availableIn(request()->ip());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [

View file

@ -117,7 +117,7 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
name="password"
:label="__('Passwort')"
autocomplete="new-password"
:placeholder="__('Mindestens 8 Zeichen')"
:placeholder="__('Mindestens 12 Zeichen')"
/>
<x-password-input

View file

@ -69,7 +69,7 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Neues Passwor
<div>
<p class="text-[13.5px] text-ink-2 leading-[1.6] !-mt-4 mb-6">
{{ __('Vergeben Sie ein neues Passwort für Ihr Konto. Mindestens 8 Zeichen, idealerweise eine Kombination aus Buchstaben, Zahlen und Sonderzeichen.') }}
{{ __('Vergeben Sie ein neues Passwort für Ihr Konto. Mindestens 12 Zeichen — am einfachsten eine Folge mehrerer Wörter.') }}
</p>
<form wire:submit="resetPassword" class="space-y-[18px]" novalidate>
@ -94,7 +94,7 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Neues Passwor
name="password"
:label="__('Neues Passwort')"
autocomplete="new-password"
:placeholder="__('Mindestens 8 Zeichen')"
:placeholder="__('Mindestens 12 Zeichen')"
/>
<x-password-input

View file

@ -7,6 +7,7 @@ use App\Mail\EmailChangeConfirmation;
use App\Models\User;
use App\Services\Auth\PasswordChanger;
use App\Services\Auth\SecurityNotifier;
use App\Services\Auth\SessionRevocation;
use App\Services\Auth\StrongAuthentication;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
@ -255,6 +256,20 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
session()->flash('security-status', __('Passkey entfernt.'));
}
/**
* Beendet alle anderen Anmeldungen des Kontos, die aktuelle bleibt
* (Sicherheitsaudit 07.10.2026, Phase 4). Nicht während einer
* Impersonation — das träfe die Geräte des Kunden.
*/
public function logoutOtherDevices(): void
{
abort_if(app(UserImpersonation::class)->isActive(), 403, __('Während der Benutzer-Impersonation können keine Geräte abgemeldet werden.'));
app(SessionRevocation::class)->logoutOtherDevices(Auth::user(), session()->driver());
session()->flash('security-status', __('Alle anderen Geräte wurden abgemeldet. Dieses Gerät bleibt angemeldet.'));
}
private function ensureNotImpersonating(): void
{
abort_if(app(UserImpersonation::class)->isActive(), 403, __('Während der Benutzer-Impersonation können keine Passkeys verwaltet werden.'));
@ -406,7 +421,7 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
</div>
<form wire:submit="updatePassword" class="p-5 space-y-4">
<flux:input wire:model="current_password" type="password" :label="__('Aktuelles Passwort')" autocomplete="current-password" required />
<flux:input wire:model="password" type="password" :label="__('Neues Passwort')" autocomplete="new-password" required />
<flux:input wire:model="password" type="password" :label="__('Neues Passwort')" :description:trailing="__('Mindestens 12 Zeichen.')" autocomplete="new-password" required />
<flux:input wire:model="password_confirmation" type="password" :label="__('Neues Passwort bestätigen')" autocomplete="new-password" required />
<div class="flex justify-end pt-2 border-t border-[color:var(--color-bg-rule)]">
<flux:button type="submit" variant="primary">{{ __('Passwort speichern') }}</flux:button>
@ -598,10 +613,15 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
{{ $sessions->count() }} {{ __('Einträge') }}
</span>
</div>
<div class="px-5 pb-2 pt-3">
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Hier sehen Sie die letzten bekannten Web-Sessions Ihres Kontos. Abmelden erfolgt aktuell über das Nutzer-Menü.') }}
<div class="flex flex-col gap-3 px-5 pb-2 pt-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0 max-w-[560px]">
{{ __('Sind Sie noch auf einem fremden oder verlorenen Gerät angemeldet? Hier beenden Sie alle anderen Anmeldungen — auch dauerhafte („Angemeldet bleiben“). Dieses Gerät bleibt angemeldet.') }}
</p>
<flux:button size="sm" variant="filled" icon="arrow-right-start-on-rectangle"
wire:click="logoutOtherDevices"
wire:confirm="{{ __('Alle anderen Geräte abmelden?') }}">
{{ __('Andere Geräte abmelden') }}
</flux:button>
</div>
<div class="divide-y divide-[color:var(--color-bg-rule)]">