Files
rspade_system/app/RSpade/Core/Session/Session.php
root 77b4d10af8 Refactor filename naming system and apply convention-based renames
Standardize settings file naming and relocate documentation files
Fix code quality violations from rsx:check
Reorganize user_management directory into logical subdirectories
Move Quill Bundle to core and align with Tom Select pattern
Simplify Site Settings page to focus on core site information
Complete Phase 5: Multi-tenant authentication with login flow and site selection
Add route query parameter rule and synchronize filename validation logic
Fix critical bug in UpdateNpmCommand causing missing JavaScript stubs
Implement filename convention rule and resolve VS Code auto-rename conflict
Implement js-sanitizer RPC server to eliminate 900+ Node.js process spawns
Implement RPC server architecture for JavaScript parsing
WIP: Add RPC server infrastructure for JS parsing (partial implementation)
Update jqhtml terminology from destroy to stop, fix datagrid DOM preservation
Add JQHTML-CLASS-01 rule and fix redundant class names
Improve code quality rules and resolve violations
Remove legacy fatal error format in favor of unified 'fatal' error type
Filter internal keys from window.rsxapp output
Update button styling and comprehensive form/modal documentation
Add conditional fly-in animation for modals
Fix non-deterministic bundle compilation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 19:10:02 +00:00

805 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';
}
/**
* Check if requester is a playwright fpc client
* @return bool
*/
private static function __is_fpc_client(): bool
{
return isset($_SERVER['HTTP_X_RSPADE_FPC_CLIENT']) && $_SERVER['HTTP_X_RSPADE_FPC_CLIENT'] === '1';
}
/**
* 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() || self::__is_fpc_client()) {
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 & FPC mode: do nothing
if (self::__is_cli() || self::__is_fpc_client()) {
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;
}
}