Files
rspade_system/app/RSpade/Core/Session/Session.php
2025-12-08 04:27:07 +00:00

796 lines
21 KiB
PHP
Executable File

<?php
namespace App\RSpade\Core\Session;
use RuntimeException;
use App\RSpade\Core\Database\Models\Rsx_System_Model_Abstract;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Models\Login_User_Model;
use App\RSpade\Core\Models\Site_Model;
use App\RSpade\Core\Models\User_Model;
/**
* Session model - handles both authentication sessions and static session management
*
* This class serves dual purposes:
* 1. As a Laravel Eloquent model for the sessions table
* 2. As a static interface for session management (similar to RS3 design)
*
* The session represents a unique browser session with persistent authentication.
* Sessions are always persistent (365 days) - no "remember me" option.
*
* @FILE-SUBCLASS-01-EXCEPTION Class intentionally named Session instead of Session_Model
* to maintain compatibility with static method calls like Session::init(). The developer will
* almost never be interacting with a session orm record directly, so the term Session_Model
* doesnt have much meaning.
*
* @property int $id
* @property bool $active
* @property int $site_id
* @property int $login_user_id
* @property int $experience_id
* @property string $session_token
* @property string $csrf_token
* @property string $ip_address
* @property string $user_agent
* @property \Carbon\Carbon $last_active
* @property int $version
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
/**
* _AUTO_GENERATED_ Database type hints - do not edit manually
* Generated on: 2025-11-04 07:18:11
* Table: sessions
*
* @property int $id
* @property bool $active
* @property int $site_id
* @property int $login_user_id
* @property mixed $session_token
* @property mixed $csrf_token
* @property mixed $ip_address
* @property mixed $user_agent
* @property string $last_active
* @property int $version
* @property string $created_at
* @property string $updated_at
* @property int $created_by
* @property int $updated_by
* @property int $experience_id
*
* @mixin \Eloquent
*/
class Session extends Rsx_System_Model_Abstract
{
// Enum definitions (required by abstract parent)
public static $enums = [];
// Static session management properties
private static $_session = null;
private static $_site = null;
private static $_login_user = null; // Authentication identity (Login_User_Model)
private static $_user = null; // Site-specific user (User_Model)
private static $_session_token = null;
private static $_has_init = false;
private static $_has_activate = false;
private static $_has_set_cookie = false;
// Experience and request-scoped overrides
private static $_experience_id = 0;
private static $_request_site_id_override = null;
// CLI mode properties (static-only, no database)
private static $_cli_site_id = null;
private static $_cli_login_user_id = null; // Authentication identity ID
private static $_cli_user_id = null; // Site-specific user ID
private static $_cli_experience_id = 0;
/**
* The table associated with the model
* @var string
*/
protected $table = '_sessions';
/**
* The attributes that should be cast
* @var array
*/
protected $casts = [
'active' => '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;
}
}