auth/security: API-Firmenzuordnung nur für Mitglieder oder Allowlist, Google-Vorabübernahme geschlossen, gemeinsamer Login-Abschluss (Umsetzungsaudit 29.09.: R-01, K-04, K-07, K-08, B-10)
- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner, Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit granted_via = api; Befehl company-memberships:api-assignments (Liste, --remove mit Vorschau) - K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort, remember_token und Sanctum-Tokens; SessionRevocation + Middleware EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich) - K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google ohne erzwungenes remember, magic-login ohne guest für eigene Links - B-10: eigener Magic-Link-Zweck image_confirmation (Migration) - K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden wird abgelehnt, Gate-Abfragen je Request zwischengespeichert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3dbc3fa673
commit
1ccb4a8479
29 changed files with 1287 additions and 73 deletions
75
app/Services/Auth/SessionRevocation.php
Normal file
75
app/Services/Auth/SessionRevocation.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?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): void
|
||||
{
|
||||
Cache::put(
|
||||
$this->cacheKey((int) $user->getKey()),
|
||||
now()->getTimestampMs(),
|
||||
now()->addMinutes(max(1, (int) config('session.lifetime', 120)) + 5),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue