first init
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\AuthCookie;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class SingleSessionMiddleware
|
||||
{
|
||||
/**
|
||||
* Cache TTL for token processing lock (seconds)
|
||||
*/
|
||||
private const PROCESSING_LOCK_TTL = 5;
|
||||
|
||||
/**
|
||||
* Cache TTL for token name cache (seconds)
|
||||
*/
|
||||
private const TOKEN_NAME_CACHE_TTL = 60;
|
||||
|
||||
/**
|
||||
* Fresh token age threshold (seconds)
|
||||
*/
|
||||
private const FRESH_TOKEN_AGE = 30;
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user) {
|
||||
try {
|
||||
$this->enforceSingleSession($user, $request);
|
||||
} catch (\Throwable $e) {
|
||||
// Log error but don't block the request
|
||||
Log::error('SingleSessionMiddleware error', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce single session per user
|
||||
*/
|
||||
private function enforceSingleSession(User $user, Request $request): void
|
||||
{
|
||||
if (app()->environment('local')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip single session enforcement during impersonation
|
||||
if ($this->isImpersonationRequest($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip single session enforcement for post-impersonation requests
|
||||
if ($this->isPostImpersonationRequest($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current token from request (Bearer header or HttpOnly cookie)
|
||||
$currentToken = $this->resolveToken($request);
|
||||
if (!$currentToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the token to get the token ID and hash
|
||||
$tokenParts = explode('|', $currentToken);
|
||||
if (count($tokenParts) !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenId = $tokenParts[0];
|
||||
$tokenHash = $tokenParts[1];
|
||||
|
||||
// Find current token record with caching
|
||||
$currentTokenRecord = $this->getTokenRecord($tokenId, $tokenHash);
|
||||
|
||||
if (!$currentTokenRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an impersonation token - skip enforcement
|
||||
if ($currentTokenRecord->name === 'impersonation-token') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip enforcement for fresh login tokens (SSO login or fresh auth-token)
|
||||
if ($this->isFreshLoginToken($currentTokenRecord, $user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use atomic cache lock per user to prevent race conditions
|
||||
$lockKey = "single_session_user_{$user->id}";
|
||||
$lock = Cache::lock($lockKey, self::PROCESSING_LOCK_TTL);
|
||||
|
||||
if (!$lock->get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all active tokens for this user (excluding impersonation), ordered by newest first
|
||||
$activeTokens = PersonalAccessToken::where('tokenable_type', get_class($user))
|
||||
->where('tokenable_id', $user->id)
|
||||
->where('name', '!=', 'impersonation-token')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
// Keep only the most recently created token (latest login); revoke all others
|
||||
if ($activeTokens->count() > 1) {
|
||||
$latestToken = $activeTokens->first();
|
||||
$idsToDelete = $activeTokens->where('id', '!=', $latestToken->id)->pluck('id');
|
||||
|
||||
PersonalAccessToken::whereIn('id', $idsToDelete)->delete();
|
||||
|
||||
$this->notifyConcurrentSession($user, $request);
|
||||
}
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token record with caching
|
||||
*/
|
||||
private function getTokenRecord(string $tokenId, string $tokenHash): ?PersonalAccessToken
|
||||
{
|
||||
$cacheKey = "token_record_{$tokenId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::TOKEN_NAME_CACHE_TTL, function () use ($tokenId, $tokenHash) {
|
||||
return PersonalAccessToken::where('id', $tokenId)
|
||||
->where('token', hash('sha256', $tokenHash))
|
||||
->first();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an impersonation-related request
|
||||
*/
|
||||
private function isImpersonationRequest(Request $request): bool
|
||||
{
|
||||
$path = $request->path();
|
||||
|
||||
// Check if the request is to impersonation endpoints
|
||||
if (str_contains($path, 'impersonate')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if there's an impersonation header
|
||||
if ($request->hasHeader('X-Original-User-Id')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the request has impersonation token
|
||||
$currentToken = $this->resolveToken($request);
|
||||
if ($currentToken) {
|
||||
$tokenParts = explode('|', $currentToken);
|
||||
if (count($tokenParts) === 2) {
|
||||
$tokenId = $tokenParts[0];
|
||||
$tokenHash = $tokenParts[1];
|
||||
|
||||
// Use cached token record to avoid duplicate queries
|
||||
$tokenRecord = $this->getTokenRecord($tokenId, $tokenHash);
|
||||
|
||||
if ($tokenRecord && $tokenRecord->name === 'impersonation-token') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a post-impersonation request (after leaving impersonation)
|
||||
*/
|
||||
private function isPostImpersonationRequest(Request $request): bool
|
||||
{
|
||||
$path = $request->path();
|
||||
|
||||
// Check if this is a request after leaving impersonation
|
||||
// Look for requests that have X-Original-User-Id header but are not impersonation endpoints
|
||||
if ($request->hasHeader('X-Original-User-Id') && !str_contains($path, 'impersonate')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current token is a fresh login token (SSO login or post-impersonation)
|
||||
* This prevents false positives when a user legitimately logs in
|
||||
*/
|
||||
private function isFreshLoginToken(PersonalAccessToken $currentTokenRecord, User $user): bool
|
||||
{
|
||||
// Check if token was created recently (within threshold)
|
||||
$tokenAge = $currentTokenRecord->created_at->diffInSeconds(now());
|
||||
|
||||
if ($tokenAge > self::FRESH_TOKEN_AGE) {
|
||||
return false; // Token is not fresh
|
||||
}
|
||||
|
||||
// For SSO tokens, if they're fresh, skip enforcement (legitimate login)
|
||||
if ($currentTokenRecord->name === 'sso-token') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for both 'authToken' (camelCase - regular login) and 'auth-token' (kebab-case - post-impersonation)
|
||||
$isAuthToken = in_array($currentTokenRecord->name, ['authToken', 'auth-token'], true);
|
||||
|
||||
if ($isAuthToken) {
|
||||
// Check if there are any other auth tokens for this user that were created before this one
|
||||
// When logging out properly, all tokens should be deleted, so if there are no older
|
||||
// auth tokens, this is likely a fresh login or post-impersonation
|
||||
$olderAuthTokens = PersonalAccessToken::where('tokenable_type', get_class($user))
|
||||
->where('tokenable_id', $user->id)
|
||||
->where('id', '!=', $currentTokenRecord->id)
|
||||
->whereIn('name', ['authToken', 'auth-token']) // Check for both naming conventions
|
||||
->where('created_at', '<', $currentTokenRecord->created_at)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->exists(); // Use exists() instead of count() for better performance
|
||||
|
||||
// If there are no older auth tokens, this is a fresh login or post-impersonation
|
||||
if (!$olderAuthTokens) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify user about concurrent session
|
||||
*/
|
||||
private function notifyConcurrentSession(User $user, Request $request): void
|
||||
{
|
||||
// Notification creation removed per user request
|
||||
// Concurrent session detection still works, but no notification is created
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Sanctum plain-text token from Authorization header or auth cookie.
|
||||
*/
|
||||
private function resolveToken(Request $request): ?string
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
|
||||
if (is_string($token) && $token !== '') {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$cookieToken = $request->cookie(AuthCookie::name());
|
||||
|
||||
return is_string($cookieToken) && $cookieToken !== '' ? $cookieToken : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user