106 lines
4.0 KiB
PHP
106 lines
4.0 KiB
PHP
<?php
|
|
|
|
namespace Modules\Core\Auth\Services;
|
|
|
|
use Illuminate\Contracts\Auth\Authenticatable;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Str;
|
|
use Modules\Core\Auth\Models\UserSession;
|
|
|
|
/**
|
|
* The record/revoke half of the session registry — see
|
|
* database/migrations/2026_09_15_000001_create_user_sessions_table.php's
|
|
* own docblock for why this exists (SESSION_DRIVER=redis in this app has
|
|
* no "sessions" table to purge by user_id). The enforcement half is
|
|
* Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked, which reads
|
|
* the token this class stamps into the session payload.
|
|
*/
|
|
class UserSessionService
|
|
{
|
|
private const SESSION_TOKEN_KEY = 'user_session_token';
|
|
|
|
/**
|
|
* Called once, right after Auth::login() succeeds (see
|
|
* UserOtpService::validate()) — generates a fresh token, records it,
|
|
* and stamps it into the CURRENT session payload so
|
|
* EnsureSessionNotRevoked can look it up on later requests.
|
|
*/
|
|
public function record(Authenticatable $user, ?Request $request = null): UserSession
|
|
{
|
|
$token = Str::random(64);
|
|
|
|
$session = UserSession::create([
|
|
'user_id' => $user->getAuthIdentifier(),
|
|
'token' => $token,
|
|
'user_agent' => $request?->userAgent(),
|
|
'ip_address' => $request?->ip(),
|
|
'last_used_at' => now(),
|
|
]);
|
|
|
|
session([self::SESSION_TOKEN_KEY => $token]);
|
|
|
|
return $session;
|
|
}
|
|
|
|
/**
|
|
* Revokes every OTHER active session for $user — the current one
|
|
* (matched by the token in the CURRENT session payload) is left
|
|
* alone, matching Laravel's own logoutOtherDevices() semantics
|
|
* (there just isn't a password to re-verify against here — this is a
|
|
* passwordless account, so revocation is simply "every row that
|
|
* isn't the one making this request").
|
|
*
|
|
* Known, deliberately accepted gap: this requires only a currently
|
|
* valid session, not a freshly-completed login — so anyone holding
|
|
* an already-authenticated session (e.g. someone who sits down at an
|
|
* account left logged in on a shared/public PC) can use this to
|
|
* evict the real owner's OTHER sessions just as easily as the real
|
|
* owner could use it to evict an intruder's. A stricter version would
|
|
* require a fresh OTP re-verification (e.g. within the last few
|
|
* minutes) before allowing this call. Left as-is for now — revisit if
|
|
* this turns out to matter in practice, rather than building
|
|
* abuse-resistance against a threat model nobody's confirmed is real
|
|
* for this storefront.
|
|
*/
|
|
public function revokeOtherSessions(Authenticatable $user): int
|
|
{
|
|
$currentToken = session(self::SESSION_TOKEN_KEY);
|
|
|
|
return UserSession::query()
|
|
->where('user_id', $user->getAuthIdentifier())
|
|
->whereNull('revoked_at')
|
|
->when($currentToken, fn ($query) => $query->where('token', '!=', $currentToken))
|
|
->update(['revoked_at' => now()]);
|
|
}
|
|
|
|
/**
|
|
* Revokes EVERY session for $user, current one included — for a
|
|
* "this account may be compromised" response, not a routine logout.
|
|
*/
|
|
public function revokeAllSessions(Authenticatable $user): int
|
|
{
|
|
return UserSession::query()
|
|
->where('user_id', $user->getAuthIdentifier())
|
|
->whereNull('revoked_at')
|
|
->update(['revoked_at' => now()]);
|
|
}
|
|
|
|
/**
|
|
* @return UserSession|null null if the CURRENT session has no
|
|
* recorded token at all (e.g. a session predating this feature, or
|
|
* one Auth::login() established outside UserOtpService) — treated
|
|
* as valid by EnsureSessionNotRevoked rather than rejected, since
|
|
* there's nothing to have been revoked.
|
|
*/
|
|
public function currentSession(): ?UserSession
|
|
{
|
|
$token = session(self::SESSION_TOKEN_KEY);
|
|
|
|
if (! $token) {
|
|
return null;
|
|
}
|
|
|
|
return UserSession::where('token', $token)->first();
|
|
}
|
|
}
|