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 @@
namespace App\Http\Middleware;
use App\Support\ContentSecurityPolicy;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@ -11,8 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
* Reverse-Proxy gesetzt, damit der Schutz auch bei Proxy-Wechsel bleibt.
* Bereits vorhandene Header (z. B. vom Proxy) werden nicht überschrieben.
* HSTS nur auf HTTPS-Antworten und laut config/security.php.
* CSP fehlt absichtlich: Livewire/Alpine/Vite brauchen eine eigene
* Inline-Script-Strategie — separat einführen, nicht nebenbei.
* CSP vorerst nur im Melde-Modus (ContentSecurityPolicy, config/security.php).
*/
class SecurityHeaders
{
@ -31,6 +31,12 @@ class SecurityHeaders
}
}
if (config('security.csp.report_only')
&& str_contains((string) $response->headers->get('Content-Type'), 'text/html')
&& ! $response->headers->has('Content-Security-Policy-Report-Only')) {
$response->headers->set('Content-Security-Policy-Report-Only', ContentSecurityPolicy::header());
}
if ($request->isSecure() && config('security.hsts.enabled') && ! $response->headers->has('Strict-Transport-Security')) {
$response->headers->set('Strict-Transport-Security', 'max-age='.(int) config('security.hsts.max_age'));
}