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; } }