presseportale/app/Services/Auth/SessionRevocation.php
Kevin Adametz bae8347f6c security: Sicherheitsaudit 07.10.2026 (S-01–S-06) behoben, Passkeys und Zweitfaktor-Pflicht für Admins
Audit: dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md, Plan und Entscheidungen:
dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md.

- S-01 Legacy-Rechnungen fremder Kunden nur mit billing:manage (auch Kundenroute).
- S-02 Kontaktzuordnung auf der Firmenanzeige verlangt companies:write, $id gesperrt.
- S-03 CompanyMembershipObserver: automatische Firmenrechte werden bei jeder
  Adress-/Zuordnungsänderung sofort neu bewertet; memberships:resync-auto.
- S-04 Passwortwechsel beendet alle anderen Sessions (PasswordChanger), Reset
  alle Sessions und offene Login-Links; Fortify PUT /user/password abgeschaltet.
  API-Tokens bleiben bestehen.
- S-05 Google: email_verified Pflicht, feste Bindung über die Provider-ID;
  Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink
  in derselben Browser-Session.
- S-06 Frischer Identitätsnachweis (Passwort, App-Code, E-Mail-Code oder
  Passkey) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung;
  Hinweis-Mails an die (bisherige) Kontoadresse.
- Entscheidung 5: Passkeys (Fortify, @laravel/passkeys) für alle; Super-Admins
  und Admins bestätigen die Session für den Admin-Bereich per Passkey oder
  App-Code (EnsureStrongAdminAuthentication, Notfallschalter
  AUTH_REQUIRE_STRONG_ADMIN); Reset des zweiten Faktors durch Super-Admins.

Neue Migration: create_passkeys_table. Suite 2.403 grün (1 übersprungen),
Larastan ohne Meldung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:09:35 +00:00

92 lines
2.9 KiB
PHP

<?php
namespace App\Services\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Login;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Facades\Cache;
/**
* Beendet alle offenen Browser-Sessions eines Kontos (Umsetzungsaudit
* 29.09.2026, K-04).
*
* Die Sessions liegen in Redis und lassen sich nicht je Nutzer aufzählen;
* Laravels `AuthenticateSession` greift nur bei gesetztem Passwort. Deshalb
* ein Zeitstempel je Konto: Jede Anmeldung schreibt ihren Zeitpunkt in die
* Session (`Login`-Event), `revokeAllFor()` merkt sich „ungültig vor jetzt",
* und die Middleware EnsureSessionIsNotRevoked meldet jede ältere Session
* beim nächsten Request ab. Die Marke lebt so lange wie eine Session
* (`session.lifetime`) — danach ist jede ältere Session ohnehin abgelaufen.
* Remember-Cookies deckt das Rotieren von `remember_token` ab, API-Tokens
* löscht der Aufrufer.
*/
class SessionRevocation
{
/**
* Anmeldezeitpunkt der Session in Millisekunden.
*/
public const SESSION_KEY = 'auth.logged_in_at_ms';
/**
* Alle bis jetzt entstandenen Sessions des Kontos werden ungültig.
*/
public function revokeAllFor(User $user): int
{
$revokedAt = now()->getTimestampMs();
Cache::put(
$this->cacheKey((int) $user->getKey()),
$revokedAt,
now()->addMinutes(max(1, (int) config('session.lifetime', 120)) + 5),
);
return $revokedAt;
}
/**
* Alle anderen Sessions des Kontos werden ungültig, die übergebene
* (aktuelle) bleibt angemeldet: Sie erhält einen Anmeldezeitpunkt nach
* der Sperre und eine neue Session-ID (Sicherheitsaudit 07.10.2026, S-04).
*/
public function revokeOthersFor(User $user, Session $current): void
{
$revokedAt = $this->revokeAllFor($user);
$current->put(self::SESSION_KEY, $revokedAt + 1);
$current->migrate(true);
}
/**
* Listener für `Login`: Zeitpunkt der Anmeldung in der Session.
* Die Methode heißt bewusst nicht `handle*` (Event-Discovery).
*/
public function stampLogin(Login $event): void
{
if (! app()->bound('request') || ! request()->hasSession()) {
return;
}
request()->session()->put(self::SESSION_KEY, now()->getTimestampMs());
}
/**
* Session stammt aus der Zeit vor der letzten Sperre dieses Kontos.
* Ohne Anmeldezeitpunkt (Session von vor dem Deploy) gilt sie als alt.
*/
public function isRevoked(int $userId, Session $session): bool
{
$revokedAt = Cache::get($this->cacheKey($userId));
if ($revokedAt === null) {
return false;
}
return (int) $session->get(self::SESSION_KEY, 0) < (int) $revokedAt;
}
private function cacheKey(int $userId): string
{
return 'auth.sessions-revoked-at.'.$userId;
}
}