presseportale/app/Services/Auth/SessionRevocation.php
Kevin Adametz d1b9ffdcf1 security: Breadcrumbs ohne Daten an GlitchTip, Übergangsmarke der Session-Sperre entfernt
- ErrorReportScrubber entfernt die Daten aller Breadcrumbs und bereinigt ihre
  Nachrichten; Livewire-Breadcrumbs standardmäßig aus — das SDK hängt dort
  Formularwerte und Properties an (Roadmap 4.9). Bestand in GlitchTip geprüft:
  keine Formular- oder Passwortdaten.
- SessionRevocation::legacyCachedMark() entfernt (Übergang bis v1.6.0)

Changelog: Sicherheit

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

100 lines
3.5 KiB
PHP

<?php
namespace App\Services\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Login;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Str;
/**
* 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. Remember-Cookies deckt das Rotieren von
* `remember_token` ab, API-Tokens löscht der Aufrufer.
*
* Die Marke steht seit v1.6.0 in `users.sessions_revoked_at_ms` und übersteht
* so ein Leeren des Caches (Sicherheitsaudit 07.10.2026, Phase 4). Die
* Middleware liest sie am ohnehin geladenen User — kein zusätzlicher Zugriff.
*/
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();
// Ohne Model-Events und ohne updated_at: Die Marke ist keine
// inhaltliche Änderung am Konto und soll keine Observer auslösen.
$user->newQuery()->whereKey($user->getKey())->update(['sessions_revoked_at_ms' => $revokedAt]);
$user->forceFill(['sessions_revoked_at_ms' => $revokedAt])->syncOriginalAttribute('sessions_revoked_at_ms');
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);
}
/**
* „Andere Geräte abmelden“ auf der Sicherheitsseite: Remember-Cookies
* anderer Geräte werden ungültig (neuer `remember_token`), alle anderen
* Sessions enden beim nächsten Request, die aktuelle bleibt angemeldet.
*/
public function logoutOtherDevices(User $user, Session $current): void
{
$user->forceFill(['remember_token' => Str::random(60)])->save();
$this->revokeOthersFor($user, $current);
}
/**
* 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(User $user, Session $session): bool
{
$revokedAt = (int) $user->sessions_revoked_at_ms;
if ($revokedAt === 0) {
return false;
}
return (int) $session->get(self::SESSION_KEY, 0) < $revokedAt;
}
}