'boolean', 'site_id' => 'integer', 'login_user_id' => 'integer', 'experience_id' => 'integer', 'version' => 'integer', 'last_active' => 'datetime', ]; /** * Columns that should never be exported to JavaScript * @var array */ protected $neverExport = [ 'session_token', 'csrf_token', 'ip_address', ]; /** * Check if running in CLI mode * @return bool */ private static function __is_cli(): bool { return php_sapi_name() === 'cli'; } /** * Initialize session from cookie or request * Loads existing session but does not create new one * Filters by current experience_id to support multiple authentication realms * In CLI mode: does nothing * @return void */ public static function init(): void { if (self::$_has_init) { return; } self::$_has_init = true; // CLI mode: do nothing if (self::__is_cli()) { return; } Manifest::init(); // Try to get session token from cookie or request $session_token = $_COOKIE['rsx'] ?? null; if (empty($session_token)) { self::$_session = null; return; } // Load session for CURRENT EXPERIENCE only // This allows same cookie to have different sessions per experience $session = static::where('session_token', $session_token) ->where('active', true) ->where('experience_id', self::$_experience_id) ->first(); if (!$session) { self::$_session = null; return; } // Update last activity (but don't save immediately to avoid version conflicts) $session->last_active = now(); // We'll let the session save happen later if needed (e.g. in set_user) // For simple page loads, we can update last_active in a separate query static::where('id', $session->id)->update(['last_active' => now()]); // Reload the session to ensure we have the latest version $session = static::find($session->id); self::$_session_token = $session_token; self::$_session = $session; self::_set_cookie(); } /** * Activate session - creates new one if needed * In CLI mode: does nothing * @return void */ private static function __activate(): void { if (self::$_has_activate) { return; } self::$_has_activate = true; // CLI mode: do nothing if (self::__is_cli()) { return; } self::init(); // If no session exists, create one if (empty(self::$_session)) { // Generate cryptographically secure token self::$_session_token = bin2hex(random_bytes(32)); // Generate CSRF token $csrf_token = bin2hex(random_bytes(32)); $session = new static(); $session->session_token = self::$_session_token; $session->csrf_token = $csrf_token; $session->ip_address = self::__get_client_ip(); $session->user_agent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255); $session->last_active = now(); $session->active = true; $session->site_id = 0; $session->experience_id = self::$_experience_id; $session->version = 1; $session->save(); self::$_session = $session; self::_set_cookie(); } } /** * Set the session cookie with security flags * In CLI mode: does nothing * @return void */ private static function _set_cookie(): void { if (self::$_has_set_cookie) { return; } self::$_has_set_cookie = true; // CLI mode: do nothing if (self::__is_cli()) { return; } // Set cookie with security flags setcookie('rsx', self::$_session_token, [ 'expires' => time() + (365 * 86400), // 1 year 'path' => '/', 'domain' => '', // Current domain only 'secure' => true, // HTTPS only 'httponly' => true, // No JavaScript access 'samesite' => 'Lax', // CSRF protection ]); } /** * Get client IP address, handling proxies * In CLI mode: returns "CLI" * @return string */ private static function __get_client_ip(): string { // CLI mode: return "CLI" if (self::__is_cli()) { return 'CLI'; } // Check for forwarded IP (when behind proxy/CDN) if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { $ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); return trim($ips[0]); } if (!empty($_SERVER['HTTP_X_REAL_IP'])) { return $_SERVER['HTTP_X_REAL_IP']; } return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } /** * Reset/logout the current session * @return void */ public static function reset(): void { self::init(); if (!empty(self::$_session)) { self::$_session->active = false; self::$_session->save(); } self::$_session = null; self::$_site = null; self::$_user = null; self::$_has_init = false; self::$_has_activate = false; self::$_has_set_cookie = false; // Clear cookie setcookie('rsx', '', [ 'expires' => time() - 3600, 'path' => '/', 'domain' => '', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ]); } /** * Get site ID for current session * Respects request-scoped override first (for subdomain enforcement) * In CLI mode: returns static CLI property * @return int */ public static function get_site_id(): int { // Request override takes precedence (subdomain enforcement) if (self::$_request_site_id_override !== null) { return self::$_request_site_id_override; } // CLI mode: return static property if (self::__is_cli()) { return self::$_cli_site_id ?? 0; } self::init(); if (empty(self::$_session)) { return 0; } return self::$_session->site_id ?? 0; } /** * Get site model for current session * @return Site_Model|null */ public static function get_site() { $site_id = self::get_site_id(); if ($site_id === 0) { return null; } if (empty(self::$_site)) { self::$_site = Site_Model::find($site_id); } return self::$_site; } /** * Get login user ID (authentication identity) for current session * In CLI mode: returns static CLI property * @return int|null */ public static function get_login_user_id() { // CLI mode: return static property if (self::__is_cli()) { return self::$_cli_login_user_id; } self::init(); if (empty(self::$_session)) { return null; } return self::$_session->login_user_id; } /** * Get site-specific user ID for current session * This is the users.id (site-specific), not login_users.id * In CLI mode: returns static CLI property * @return int|null */ public static function get_user_id() { // CLI mode: return static property if (self::__is_cli()) { return self::$_cli_user_id; } $user = self::get_user(); return $user ? $user->id : null; } /** * Check if user is logged in * @return bool */ public static function is_logged_in(): bool { return !empty(self::get_login_user_id()); } /** * Get login user model (authentication identity) for current session * @return Login_User_Model|null */ public static function get_login_user() { $login_user_id = self::get_login_user_id(); if (empty($login_user_id)) { return null; } if (empty(self::$_login_user)) { self::$_login_user = Login_User_Model::find($login_user_id); } return self::$_login_user; } /** * Get site-specific user model for current session * @return User_Model|null */ public static function get_user() { $login_user_id = self::get_login_user_id(); $site_id = self::get_site_id(); if (empty($login_user_id) || empty($site_id)) { return null; } if (empty(self::$_user)) { self::$_user = User_Model::where('login_user_id', $login_user_id) ->where('site_id', $site_id) ->first(); } return self::$_user; } /** * Get current session model (creates if needed) * @return Session */ public static function get_session(): Session { self::__activate(); return self::$_session; } /** * Get current session ID (creates session if needed) * @return int */ public static function get_session_id(): int { self::__activate(); return self::$_session->id; } /** * Get CSRF token for current session * @return string|null */ public static function get_csrf_token(): ?string { self::init(); if (empty(self::$_session)) { return null; } return self::$_session->csrf_token; } /** * Verify CSRF token * @param string $token * @return bool */ public static function verify_csrf_token(string $token): bool { self::init(); if (empty(self::$_session)) { return false; } // Use constant-time comparison return hash_equals(self::$_session->csrf_token, $token); } /** * Logout current user * @return void */ public static function logout(): void { self::set_login_user_id(null); } /** * Set login user ID for current session (login/logout) * In CLI mode: sets static CLI property only, no database * @param int|null $login_user_id Login user ID, or null to logout * @return void */ public static function set_login_user_id(?int $login_user_id): void { // Logout if null/0 if (empty($login_user_id)) { // CLI mode: clear static property only if (self::__is_cli()) { self::$_cli_login_user_id = null; self::$_cli_user_id = null; self::$_login_user = null; self::$_user = null; self::$_site = null; return; } self::__activate(); self::$_session->login_user_id = null; self::$_session->save(); self::$_login_user = null; self::$_user = null; self::$_site = null; return; } // CLI mode: set static property only if (self::__is_cli()) { self::$_cli_login_user_id = $login_user_id; self::$_cli_user_id = null; self::$_login_user = null; self::$_user = null; self::$_site = null; return; } self::__activate(); // Regenerate session token and CSRF token on login (prevent session fixation) $new_token = bin2hex(random_bytes(32)); $new_csrf = bin2hex(random_bytes(32)); self::$_session->session_token = $new_token; self::$_session->csrf_token = $new_csrf; self::$_session->login_user_id = $login_user_id; self::$_session->version++; self::$_session->save(); self::$_session_token = $new_token; self::$_has_set_cookie = false; // Force new cookie self::_set_cookie(); // Clear cached login_user/user/site self::$_login_user = null; self::$_user = null; self::$_site = null; // Update login user's last login timestamp $login_user_record = self::get_login_user(); if ($login_user_record) { $login_user_record->last_login = now(); $login_user_record->save(); } } /** * Set site for current session * In CLI mode: sets static CLI property only, no database * @param Site_Model|int $site Site model or site ID * @return void */ /** * Set site ID * In CLI mode: sets static CLI property only * @param int $site_id * @return void */ public static function set_site_id(int $site_id): void { // CLI mode: set static property only if (self::__is_cli()) { self::$_cli_site_id = $site_id; self::$_site = null; return; } self::__activate(); // Skip if already set if (self::get_site_id() === $site_id) { return; } self::$_session->site_id = $site_id; self::$_session->version++; self::$_session->save(); // Clear cached site self::$_site = null; } /** * Check if a session exists * In CLI mode: returns true if site_id or user_id is set * In web mode: returns true if session record exists * @return bool */ public static function has_session(): bool { // CLI mode: check if site_id or user_id is set if (self::__is_cli()) { return self::$_cli_site_id !== null || self::$_cli_user_id !== null; } // Web mode: init and check if session exists self::init(); return !empty(self::$_session); } /** * Get session by token (for API/external access) * @param string $token * @return Session|null */ public static function find_by_token(string $token) { return static::where('session_token', $token) ->where('active', true) ->first(); } /** * Clean up expired sessions (garbage collection) * @param int $days_until_expiry * @return int Number of sessions deleted */ public static function cleanup_expired(int $days_until_expiry = 365): int { return static::where('last_active', '<', now()->subDays($days_until_expiry)) ->delete(); } /** * Override save to increment version on updates * @param array $options * @return bool */ public function save(array $options = []): bool { // Increment version on updates (but don't check for conflicts since sessions // are single-user and shouldn't have real concurrent modifications) if ($this->exists && $this->isDirty()) { $this->version = ($this->version ?? 1) + 1; } return parent::save($options); } /** * Relationship: Login User (authentication identity) * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function login_user() { return $this->belongsTo(Login_User_Model::class, 'login_user_id'); } /** * Relationship: Site * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function site() { return $this->belongsTo(Site_Model::class, 'site_id'); } /** * Relationship: Site-specific User (composite key relationship) * Returns the User_Model that matches both login_user_id and site_id * @return \Illuminate\Database\Eloquent\Relations\HasOne */ public function user() { return $this->hasOne(User_Model::class, 'login_user_id', 'login_user_id') ->where('users.site_id', '=', $this->site_id); } /** * Set experience context for current request * Determines which authentication realm we're in (default=0, staff=1, customer=2, etc.) * Does NOT persist to database - request-scoped only * Clears cached session/user/site since experience changed * In CLI mode: sets static CLI property * @param int $experience_id * @return void */ public static function set_experience_id(int $experience_id): void { if (self::__is_cli()) { self::$_cli_experience_id = $experience_id; return; } self::$_experience_id = $experience_id; // Clear cached data since experience changed self::$_user = null; self::$_site = null; self::$_session = null; // Force re-init with new experience context self::$_has_init = false; self::init(); } /** * Get current experience ID * In CLI mode: returns static CLI property * @return int */ public static function get_experience_id(): int { if (self::__is_cli()) { return self::$_cli_experience_id; } return self::$_experience_id; } /** * Set site_id override for current request (subdomain enforcement) * This overrides the database session site_id for THIS REQUEST ONLY * Does NOT modify the session record * Use case: User visits subdomain assigned to a tenant, enforce that tenant * @param int $site_id * @return void */ public static function set_request_site_id_override(int $site_id): void { self::$_request_site_id_override = $site_id; self::$_site = null; // Clear cached site } /** * Clear site_id override (return to normal session-based site_id) * @return void */ public static function clear_request_site_id_override(): void { self::$_request_site_id_override = null; self::$_site = null; // Clear cached site } /** * Check if request has site_id override active * @return bool */ public static function has_request_site_id_override(): bool { return self::$_request_site_id_override !== null; } // ========================================================================= // SESSION MANAGEMENT METHODS // ========================================================================= /** * Get all active sessions for a login user * Returns formatted session info including device/location parsing * * @param int|null $login_user_id If null, uses current logged-in user * @return array Array of session info */ public static function get_sessions_for_user(?int $login_user_id = null): array { if ($login_user_id === null) { $login_user_id = self::get_login_user_id(); } if (empty($login_user_id)) { return []; } $current_session_id = self::has_session() ? self::$_session?->id : null; return static::where('login_user_id', $login_user_id) ->where('active', true) ->orderBy('last_active', 'desc') ->get() ->map(function ($session) use ($current_session_id) { $parsed_ua = User_Agent::parse($session->user_agent); return [ 'id' => $session->id, 'ip_address' => $session->ip_address, 'user_agent' => $session->user_agent, 'user_agent_parsed' => $parsed_ua, 'device_summary' => $parsed_ua['summary'], 'location' => self::_format_session_location($session), 'last_active' => $session->last_active, 'created_at' => $session->created_at, 'is_current' => $session->id === $current_session_id, ]; }) ->toArray(); } /** * Terminate a specific session by ID * Cannot terminate the current session (use logout() instead) * * @param int $session_id * @return bool True if session was terminated, false if not found or is current */ public static function terminate_session(int $session_id): bool { self::init(); // Don't allow terminating current session if (self::$_session && self::$_session->id === $session_id) { return false; } $affected = static::where('id', $session_id) ->where('active', true) ->update(['active' => false]); return $affected > 0; } /** * Terminate all sessions for the current user except the current one * * @return int Number of sessions terminated */ public static function terminate_all_other_sessions(): int { self::init(); $login_user_id = self::get_login_user_id(); if (empty($login_user_id)) { return 0; } $query = static::where('login_user_id', $login_user_id) ->where('active', true); // Exclude current session if we have one if (self::$_session) { $query->where('id', '!=', self::$_session->id); } return $query->update(['active' => false]); } /** * Terminate all sessions for a specific login user * Useful for admin actions or password changes * * @param int $login_user_id * @param int|null $except_session_id Optional session ID to exclude * @return int Number of sessions terminated */ public static function terminate_all_sessions_for_user(int $login_user_id, ?int $except_session_id = null): int { $query = static::where('login_user_id', $login_user_id) ->where('active', true); if ($except_session_id !== null) { $query->where('id', '!=', $except_session_id); } return $query->update(['active' => false]); } /** * Get information about the current session * Returns formatted info with device/location parsing * * @return array|null Session info or null if not logged in */ public static function get_current_session_info(): ?array { self::init(); if (empty(self::$_session)) { return null; } $parsed_ua = User_Agent::parse(self::$_session->user_agent); return [ 'id' => self::$_session->id, 'ip_address' => self::$_session->ip_address, 'user_agent' => self::$_session->user_agent, 'user_agent_parsed' => $parsed_ua, 'device_summary' => $parsed_ua['summary'], 'location' => self::_format_session_location(self::$_session), 'last_active' => self::$_session->last_active, 'created_at' => self::$_session->created_at, 'is_current' => true, ]; } /** * Format location string from session (placeholder for future geo lookup) * * @param Session $session * @return string|null */ private static function _format_session_location($session): ?string { // Future: Implement geo lookup based on IP // For now, return null (location not available) return null; } }