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:
parent
9c4962fbe0
commit
5dfd622141
27 changed files with 601 additions and 39 deletions
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue