From 18928c86787c9d059797502fff7b67a37b6bd554 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 11 Dec 2025 05:26:44 +0000 Subject: [PATCH] Add login history tracking and session management features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Core/Files/File_Attachment_Model.php | 74 +++--- app/RSpade/Core/Models/User_Model.php | 71 +++--- .../Core/Models/User_Verification_Model.php | 50 ++-- app/RSpade/Core/Session/Login_History.php | 225 ++++++++++++++++++ app/RSpade/Core/Session/Session.php | 156 ++++++++++++ app/RSpade/Core/Session/User_Agent.php | 173 ++++++++++++++ app/RSpade/Lib/Flash/Flash_Alert_Model.php | 43 ++-- app/RSpade/man/session.txt | 191 +++++++++++++++ database/migrations/.migration_whitelist | 5 + ...2_11_050610_create_login_history_table.php | 35 +++ 10 files changed, 881 insertions(+), 142 deletions(-) create mode 100755 app/RSpade/Core/Session/Login_History.php create mode 100755 app/RSpade/Core/Session/User_Agent.php create mode 100755 database/migrations/2025_12_11_050610_create_login_history_table.php diff --git a/app/RSpade/Core/Files/File_Attachment_Model.php b/app/RSpade/Core/Files/File_Attachment_Model.php index 0c17e88e0..66d21b530 100755 --- a/app/RSpade/Core/Files/File_Attachment_Model.php +++ b/app/RSpade/Core/Files/File_Attachment_Model.php @@ -30,51 +30,42 @@ use App\RSpade\Core\Files\File_Storage_Model; * provides the basic structure for categorizing uploaded files. */ + /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2025-12-10 02:59:31 - * Table: _file_attachments - * - * @property int $id - * @property mixed $key - * @property int $file_storage_id - * @property mixed $file_name - * @property mixed $file_extension - * @property int $file_type_id - * @property int $width - * @property int $height - * @property int $duration - * @property bool $is_animated - * @property int $frame_count - * @property mixed $fileable_type - * @property int $fileable_id - * @property mixed $fileable_category - * @property mixed $fileable_type_meta - * @property int $fileable_order + * _AUTO_GENERATED_ + * @property integer $id + * @property string $key + * @property integer $file_storage_id + * @property string $file_name + * @property string $file_extension + * @property integer $file_type_id + * @property integer $width + * @property integer $height + * @property integer $duration + * @property boolean $is_animated + * @property integer $frame_count + * @property string $fileable_type + * @property integer $fileable_id + * @property string $fileable_category + * @property string $fileable_type_meta + * @property integer $fileable_order * @property string $fileable_meta - * @property int $site_id - * @property mixed $session_id - * @property string $created_at - * @property string $updated_at - * @property int $created_by - * @property int $updated_by - * - * @property-read string $file_type_id_label - * @property-read string $file_type_id_constant - * @property-read array $file_type_id_enum_val - * - * @method static array file_type_id_enum_val() Get all enum definitions with full metadata - * @method static array file_type_id_enum_select() Get selectable items for dropdowns - * @method static array file_type_id_enum_labels() Get simple id => label map - * @method static array file_type_id_enum_ids() Get array of all valid enum IDs - * + * @property integer $site_id + * @property string $session_id + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property integer $created_by + * @property integer $updated_by + * @method static mixed file_type_id_enum() + * @method static mixed file_type_id_enum_select() + * @method static mixed file_type_id_enum_ids() + * @property-read mixed $file_type_id_constant + * @property-read mixed $file_type_id_label * @mixin \Eloquent */ class File_Attachment_Model extends Rsx_Site_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const FILE_TYPE_IMAGE = 1; const FILE_TYPE_ANIMATED_IMAGE = 2; const FILE_TYPE_VIDEO = 3; @@ -82,9 +73,6 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract const FILE_TYPE_TEXT = 5; const FILE_TYPE_DOCUMENT = 6; const FILE_TYPE_OTHER = 7; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ /** diff --git a/app/RSpade/Core/Models/User_Model.php b/app/RSpade/Core/Models/User_Model.php index 6b405bca3..d0546a607 100755 --- a/app/RSpade/Core/Models/User_Model.php +++ b/app/RSpade/Core/Models/User_Model.php @@ -23,47 +23,41 @@ use App\RSpade\Core\Models\User_Profile_Model; * See: php artisan rsx:man acls */ + /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2025-12-10 02:59:32 - * Table: users - * - * @property int $id - * @property int $login_user_id - * @property int $site_id - * @property mixed $first_name - * @property mixed $last_name - * @property mixed $phone - * @property int $role_id - * @property bool $is_enabled - * @property int $user_role_id - * @property mixed $email - * @property string $deleted_at - * @property string $created_at - * @property string $updated_at - * @property int $created_by - * @property int $updated_by - * @property int $deleted_by - * @property mixed $invite_code - * @property string $invite_accepted_at - * @property string $invite_expires_at - * - * @property-read string $role_id_label - * @property-read string $role_id_constant - * @property-read array $role_id_enum_val - * - * @method static array role_id_enum_val() Get all enum definitions with full metadata - * @method static array role_id_enum_select() Get selectable items for dropdowns - * @method static array role_id_enum_labels() Get simple id => label map - * @method static array role_id_enum_ids() Get array of all valid enum IDs - * + * _AUTO_GENERATED_ + * @property integer $id + * @property integer $login_user_id + * @property integer $site_id + * @property string $first_name + * @property string $last_name + * @property string $phone + * @property integer $role_id + * @property boolean $is_enabled + * @property integer $user_role_id + * @property string $email + * @property \Carbon\Carbon $deleted_at + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property integer $created_by + * @property integer $updated_by + * @property integer $deleted_by + * @property string $invite_code + * @property \Carbon\Carbon $invite_accepted_at + * @property \Carbon\Carbon $invite_expires_at + * @method static mixed role_id_enum() + * @method static mixed role_id_enum_select() + * @method static mixed role_id_enum_ids() + * @property-read mixed $role_id_constant + * @property-read mixed $role_id_label + * @property-read mixed $role_id_permissions + * @property-read mixed $role_id_can_admin_roles + * @property-read mixed $role_id_selectable * @mixin \Eloquent */ class User_Model extends Rsx_Site_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const ROLE_DEVELOPER = 100; const ROLE_ROOT_ADMIN = 200; const ROLE_SITE_OWNER = 300; @@ -72,9 +66,6 @@ class User_Model extends Rsx_Site_Model_Abstract const ROLE_USER = 600; const ROLE_VIEWER = 700; const ROLE_DISABLED = 800; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ // ========================================================================= diff --git a/app/RSpade/Core/Models/User_Verification_Model.php b/app/RSpade/Core/Models/User_Verification_Model.php index b4b904fb7..bdad29a8e 100755 --- a/app/RSpade/Core/Models/User_Verification_Model.php +++ b/app/RSpade/Core/Models/User_Verification_Model.php @@ -11,45 +11,33 @@ use App\RSpade\Core\Database\Models\Rsx_Model_Abstract; * and two-factor authentication via email or SMS. */ + /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2025-12-10 02:59:32 - * Table: user_verifications - * - * @property int $id - * @property mixed $email - * @property mixed $verification_code - * @property int $verification_type_id - * @property string $verified_at - * @property string $expires_at - * @property string $created_at - * @property string $updated_at - * @property int $created_by - * @property int $updated_by - * - * @property-read string $verification_type_id_label - * @property-read string $verification_type_id_constant - * @property-read array $verification_type_id_enum_val - * - * @method static array verification_type_id_enum_val() Get all enum definitions with full metadata - * @method static array verification_type_id_enum_select() Get selectable items for dropdowns - * @method static array verification_type_id_enum_labels() Get simple id => label map - * @method static array verification_type_id_enum_ids() Get array of all valid enum IDs - * + * _AUTO_GENERATED_ + * @property integer $id + * @property string $email + * @property string $verification_code + * @property integer $verification_type_id + * @property \Carbon\Carbon $verified_at + * @property \Carbon\Carbon $expires_at + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property integer $created_by + * @property integer $updated_by + * @method static mixed verification_type_id_enum() + * @method static mixed verification_type_id_enum_select() + * @method static mixed verification_type_id_enum_ids() + * @property-read mixed $verification_type_id_constant + * @property-read mixed $verification_type_id_label * @mixin \Eloquent */ class User_Verification_Model extends Rsx_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const VERIFICATION_TYPE_EMAIL = 1; const VERIFICATION_TYPE_SMS = 2; const VERIFICATION_TYPE_EMAIL_RECOVERY = 3; const VERIFICATION_TYPE_SMS_RECOVERY = 4; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ /** diff --git a/app/RSpade/Core/Session/Login_History.php b/app/RSpade/Core/Session/Login_History.php new file mode 100755 index 000000000..c8b7a0daa --- /dev/null +++ b/app/RSpade/Core/Session/Login_History.php @@ -0,0 +1,225 @@ +insert([ + 'login_user_id' => $login_user_id, + 'email_attempted' => $email, + 'ip_address' => self::_get_client_ip(), + 'user_agent' => self::_get_user_agent(), + 'status' => $status, + 'failure_reason' => $reason, + 'created_at' => now(), + ]); + } + + /** + * Get login history for a specific user + * + * @param int $login_user_id + * @param int $limit Maximum number of records to return + * @return array Array of login history records + */ + public static function get_history_for_user(int $login_user_id, int $limit = 10): array + { + return DB::table('_login_history') + ->where('login_user_id', $login_user_id) + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get() + ->map(function ($record) { + return [ + 'id' => $record->id, + 'email' => $record->email_attempted, + 'ip_address' => $record->ip_address, + 'user_agent' => $record->user_agent, + 'user_agent_parsed' => User_Agent::parse($record->user_agent), + 'location' => self::_format_location($record), + 'status' => $record->status, + 'status_label' => self::_get_status_label($record->status), + 'failure_reason' => $record->failure_reason, + 'created_at' => $record->created_at, + ]; + }) + ->toArray(); + } + + /** + * Get count of failed login attempts for an email within a time window + * Useful for rate limiting / lockout logic + * + * @param string $email + * @param int $minutes Time window in minutes + * @return int Number of failed attempts + */ + public static function get_failed_attempts_count(string $email, int $minutes = 15): int + { + return DB::table('_login_history') + ->where('email_attempted', $email) + ->where('status', '!=', self::STATUS_SUCCESS) + ->where('created_at', '>=', now()->subMinutes($minutes)) + ->count(); + } + + /** + * Get count of failed login attempts from an IP within a time window + * Useful for IP-based rate limiting + * + * @param string $ip_address + * @param int $minutes Time window in minutes + * @return int Number of failed attempts + */ + public static function get_failed_attempts_count_by_ip(string $ip_address, int $minutes = 15): int + { + return DB::table('_login_history') + ->where('ip_address', $ip_address) + ->where('status', '!=', self::STATUS_SUCCESS) + ->where('created_at', '>=', now()->subMinutes($minutes)) + ->count(); + } + + /** + * Get client IP address, handling proxies + * + * @return string + */ + private static function _get_client_ip(): string + { + if (php_sapi_name() === 'cli') { + return 'CLI'; + } + + 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'; + } + + /** + * Get user agent string + * + * @return string|null + */ + private static function _get_user_agent(): ?string + { + if (php_sapi_name() === 'cli') { + return 'CLI'; + } + + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null; + return $user_agent ? substr($user_agent, 0, 512) : null; + } + + /** + * Format location from record fields + * + * @param object $record + * @return string|null + */ + private static function _format_location(object $record): ?string + { + $parts = array_filter([ + $record->location_city, + $record->location_region, + $record->location_country, + ]); + + return !empty($parts) ? implode(', ', $parts) : null; + } + + /** + * Get human-readable status label + * + * @param string $status + * @return string + */ + private static function _get_status_label(string $status): string + { + return match ($status) { + self::STATUS_SUCCESS => 'Success', + self::STATUS_FAILED_PASSWORD => 'Failed - Invalid Password', + self::STATUS_FAILED_2FA => 'Failed - 2FA Verification', + self::STATUS_FAILED_LOCKED => 'Failed - Account Locked', + self::STATUS_FAILED_DISABLED => 'Failed - Account Disabled', + self::STATUS_FAILED_NOT_FOUND => 'Failed - User Not Found', + default => ucfirst(str_replace('_', ' ', $status)), + }; + } +} diff --git a/app/RSpade/Core/Session/Session.php b/app/RSpade/Core/Session/Session.php index b29549232..01fc41231 100755 --- a/app/RSpade/Core/Session/Session.php +++ b/app/RSpade/Core/Session/Session.php @@ -8,6 +8,7 @@ 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; +use App\RSpade\Core\Session\User_Agent; /** * Session model - handles both authentication sessions and static session management @@ -792,4 +793,159 @@ class Session extends Rsx_System_Model_Abstract { 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; + } } diff --git a/app/RSpade/Core/Session/User_Agent.php b/app/RSpade/Core/Session/User_Agent.php new file mode 100755 index 000000000..2737ffae8 --- /dev/null +++ b/app/RSpade/Core/Session/User_Agent.php @@ -0,0 +1,173 @@ + 'Unknown', + 'os' => 'Unknown', + 'device' => 'Unknown', + 'summary' => 'Unknown', + ]; + } + + $browser = self::_detect_browser($user_agent); + $os = self::_detect_os($user_agent); + $device = self::_detect_device($user_agent); + + return [ + 'browser' => $browser, + 'os' => $os, + 'device' => $device, + 'summary' => "{$browser} on {$os}", + ]; + } + + /** + * Detect browser from user agent + * + * @param string $user_agent + * @return string + */ + private static function _detect_browser(string $user_agent): string + { + // Order matters - check more specific browsers first + $browsers = [ + 'Edg/' => 'Edge', + 'Edge/' => 'Edge', + 'OPR/' => 'Opera', + 'Opera' => 'Opera', + 'Brave' => 'Brave', + 'Vivaldi' => 'Vivaldi', + 'Firefox/' => 'Firefox', + 'Chrome/' => 'Chrome', + 'Safari/' => 'Safari', + 'MSIE ' => 'Internet Explorer', + 'Trident/' => 'Internet Explorer', + ]; + + foreach ($browsers as $pattern => $name) { + if (stripos($user_agent, $pattern) !== false) { + // Special case: Safari check - Chrome/Edge/Opera all contain Safari + if ($name === 'Safari') { + if (stripos($user_agent, 'Chrome') !== false || + stripos($user_agent, 'Chromium') !== false || + stripos($user_agent, 'Edg') !== false || + stripos($user_agent, 'OPR') !== false) { + continue; + } + } + return $name; + } + } + + return 'Unknown Browser'; + } + + /** + * Detect operating system from user agent + * + * @param string $user_agent + * @return string + */ + private static function _detect_os(string $user_agent): string + { + // Order matters - check more specific patterns first + $os_patterns = [ + 'iPhone' => 'iOS', + 'iPad' => 'iPadOS', + 'Android' => 'Android', + 'Windows NT 10' => 'Windows', + 'Windows NT 6.3' => 'Windows', + 'Windows NT 6.2' => 'Windows', + 'Windows NT 6.1' => 'Windows', + 'Windows' => 'Windows', + 'Mac OS X' => 'macOS', + 'Macintosh' => 'macOS', + 'CrOS' => 'Chrome OS', + 'Ubuntu' => 'Ubuntu', + 'Fedora' => 'Fedora', + 'Linux' => 'Linux', + ]; + + foreach ($os_patterns as $pattern => $name) { + if (stripos($user_agent, $pattern) !== false) { + return $name; + } + } + + return 'Unknown OS'; + } + + /** + * Detect device type from user agent + * + * @param string $user_agent + * @return string + */ + private static function _detect_device(string $user_agent): string + { + // Check for mobile indicators + $mobile_patterns = [ + 'iPhone', + 'Android.*Mobile', + 'Windows Phone', + 'BlackBerry', + 'Opera Mini', + 'IEMobile', + ]; + + foreach ($mobile_patterns as $pattern) { + if (preg_match('/' . $pattern . '/i', $user_agent)) { + return 'Mobile'; + } + } + + // Check for tablet indicators + $tablet_patterns = [ + 'iPad', + 'Android(?!.*Mobile)', + 'Tablet', + ]; + + foreach ($tablet_patterns as $pattern) { + if (preg_match('/' . $pattern . '/i', $user_agent)) { + return 'Tablet'; + } + } + + return 'Desktop'; + } + + /** + * Get a short summary suitable for display + * e.g., "Chrome on Windows" + * + * @param string|null $user_agent + * @return string + */ + public static function get_summary(?string $user_agent): string + { + return self::parse($user_agent)['summary']; + } +} diff --git a/app/RSpade/Lib/Flash/Flash_Alert_Model.php b/app/RSpade/Lib/Flash/Flash_Alert_Model.php index 3674a8311..e6361dada 100755 --- a/app/RSpade/Lib/Flash/Flash_Alert_Model.php +++ b/app/RSpade/Lib/Flash/Flash_Alert_Model.php @@ -5,42 +5,29 @@ namespace App\RSpade\Lib\Flash; use App\RSpade\Core\Database\Models\Rsx_Model_Abstract; /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2025-12-10 02:59:33 - * Table: _flash_alerts - * - * @property int $id - * @property int $session_id - * @property int $type_id + * _AUTO_GENERATED_ + * @property integer $id + * @property integer $session_id + * @property integer $type_id * @property string $message - * @property string $created_at - * @property int $created_by - * @property int $updated_by - * @property string $updated_at - * - * @property-read string $type_id_label - * @property-read string $type_id_constant - * @property-read array $type_id_enum_val - * - * @method static array type_id_enum_val() Get all enum definitions with full metadata - * @method static array type_id_enum_select() Get selectable items for dropdowns - * @method static array type_id_enum_labels() Get simple id => label map - * @method static array type_id_enum_ids() Get array of all valid enum IDs - * + * @property \Carbon\Carbon $created_at + * @property integer $created_by + * @property integer $updated_by + * @property \Carbon\Carbon $updated_at + * @method static mixed type_id_enum() + * @method static mixed type_id_enum_select() + * @method static mixed type_id_enum_ids() + * @property-read mixed $type_id_constant + * @property-read mixed $type_id_label * @mixin \Eloquent */ class Flash_Alert_Model extends Rsx_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const TYPE_SUCCESS = 1; const TYPE_ERROR = 2; const TYPE_INFO = 3; const TYPE_WARNING = 4; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ // Enum constants (auto-generated by rsx:migrate:document_models) diff --git a/app/RSpade/man/session.txt b/app/RSpade/man/session.txt index 25a471f6d..01cae8ec3 100755 --- a/app/RSpade/man/session.txt +++ b/app/RSpade/man/session.txt @@ -530,6 +530,197 @@ GARBAGE COLLECTION - Emergency cleanup when disk space is low - One-time cleanup after changing retention policies +SESSION MANAGEMENT + Multi-Device Session Tracking: + RSX tracks all active sessions for each user, allowing users to view + and manage their active sessions across devices. + + Get All Sessions for User: + // Get all active sessions for current user + $sessions = Session::get_sessions_for_user(); + + // Get sessions for a specific user (admin use) + $sessions = Session::get_sessions_for_user($login_user_id); + + Each session includes: + - id: Session ID + - ip_address: Client IP + - user_agent: Raw user agent string + - user_agent_parsed: Parsed browser/OS/device info + - device_summary: Human-readable string (e.g., "Chrome on Windows") + - location: Geo location (null until geo lookup implemented) + - last_active: Last activity timestamp + - created_at: Session creation timestamp + - is_current: Boolean indicating if this is the current session + + Get Current Session Info: + $info = Session::get_current_session_info(); + // Returns same structure as above, or null if not logged in + + Terminate Sessions: + // Terminate a specific session (cannot terminate current) + $success = Session::terminate_session($session_id); + + // Terminate all other sessions (keep current) + $count = Session::terminate_all_other_sessions(); + + // Terminate all sessions for a user (admin action) + $count = Session::terminate_all_sessions_for_user($login_user_id); + + // Terminate all except one specific session + $count = Session::terminate_all_sessions_for_user($login_user_id, $except_id); + + Use Cases: + - "Sign out all other devices" button + - Security page showing active sessions + - Admin forcing user logout after password change + - Detecting suspicious login locations + +LOGIN HISTORY + The Login_History class provides audit logging of all login attempts. + This is separate from sessions - it records the attempt itself, not + the resulting session. + + Class: App\RSpade\Core\Session\Login_History + + Recording Login Attempts: + use App\RSpade\Core\Session\Login_History; + + // Record successful login + Login_History::record_success($login_user_id, $email); + + // Record failed login + Login_History::record_failure( + $email, + Login_History::STATUS_FAILED_PASSWORD, + 'Optional reason details', + $login_user_id // Optional, if user exists + ); + + Status Constants: + Login_History::STATUS_SUCCESS - Successful login + Login_History::STATUS_FAILED_PASSWORD - Wrong password + Login_History::STATUS_FAILED_2FA - 2FA verification failed + Login_History::STATUS_FAILED_LOCKED - Account locked + Login_History::STATUS_FAILED_DISABLED - Account disabled + Login_History::STATUS_FAILED_NOT_FOUND - User not found + + Retrieving Login History: + // Get recent login history for a user + $history = Login_History::get_history_for_user($login_user_id, $limit = 10); + + Each record includes: + - id: Record ID + - email: Email attempted + - ip_address: Client IP + - user_agent: Raw user agent string + - user_agent_parsed: Parsed browser/OS/device info + - location: Geo location (null until implemented) + - status: Status constant value + - status_label: Human-readable status (e.g., "Success", "Failed - Invalid Password") + - failure_reason: Optional failure details + - created_at: Timestamp + + Rate Limiting Support: + // Count failed attempts for an email in time window + $count = Login_History::get_failed_attempts_count($email, $minutes = 15); + + // Count failed attempts from an IP + $count = Login_History::get_failed_attempts_count_by_ip($ip, $minutes = 15); + + Example rate limiting: + $failed = Login_History::get_failed_attempts_count($email, 15); + if ($failed >= 5) { + return response_error(Ajax::ERROR_GENERIC, 'Too many failed attempts'); + } + + Integration with Login Flow: + public static function login(Request $request, array $params = []) + { + $email = $params['email']; + $password = $params['password']; + + $user = Login_User_Model::where('email', $email)->first(); + + if (!$user) { + Login_History::record_failure($email, Login_History::STATUS_FAILED_NOT_FOUND); + return response_error(Ajax::ERROR_VALIDATION, ['email' => 'Invalid credentials']); + } + + if (!password_verify($password, $user->password)) { + Login_History::record_failure( + $email, + Login_History::STATUS_FAILED_PASSWORD, + null, + $user->id + ); + return response_error(Ajax::ERROR_VALIDATION, ['email' => 'Invalid credentials']); + } + + // Success + Login_History::record_success($user->id, $email); + Session::set_login_user_id($user->id); + + return ['redirect' => Rsx::Route('Dashboard_Index_Action')]; + } + +USER AGENT PARSING + The User_Agent class parses user agent strings into human-readable + components for display in session/login history UIs. + + Class: App\RSpade\Core\Session\User_Agent + + Basic Usage: + use App\RSpade\Core\Session\User_Agent; + + $parsed = User_Agent::parse($user_agent_string); + // Returns: + // [ + // 'browser' => 'Chrome', + // 'os' => 'Windows', + // 'device' => 'Desktop', + // 'summary' => 'Chrome on Windows' + // ] + + // Quick summary only + $summary = User_Agent::get_summary($user_agent_string); + // Returns: "Chrome on Windows" + + Detected Browsers: + Chrome, Firefox, Safari, Edge, Opera, Brave, Vivaldi, Internet Explorer + + Detected Operating Systems: + Windows, macOS, iOS, iPadOS, Android, Linux, Chrome OS, Ubuntu, Fedora + + Detected Device Types: + Desktop, Mobile, Tablet + + Notes: + - This is a simple parser for display purposes + - Not intended for feature detection or security decisions + - For comprehensive parsing, use a dedicated library like WhichBrowser + - Location fields are placeholders for future geo-IP integration + +LOCATION GEOLOCATION (FUTURE) + The login_history and session info structures include location fields + that are currently null. These are placeholders for future integration + with an IP geolocation service. + + Fields reserved: + - location_city: City name + - location_region: State/province + - location_country: ISO country code (2 characters) + + When implemented, the system will: + - Look up IP address on login/session creation + - Cache results to avoid repeated lookups + - Populate location fields automatically + + Candidate services for future integration: + - MaxMind GeoIP2 (local database, most accurate) + - ip-api.com (free tier available) + - ipinfo.io (generous free tier) + SEE ALSO rsx:man routing - Type-safe URL generation rsx:man model_fetch - Ajax ORM with security diff --git a/database/migrations/.migration_whitelist b/database/migrations/.migration_whitelist index 3404986b9..e0a07d1f6 100755 --- a/database/migrations/.migration_whitelist +++ b/database/migrations/.migration_whitelist @@ -336,6 +336,11 @@ "created_at": "2025-12-10T02:06:33+00:00", "created_by": "root", "command": "php artisan make:migration:safe set_user_1_invite_accepted" + }, + "2025_12_11_050610_create_login_history_table.php": { + "created_at": "2025-12-11T05:06:10+00:00", + "created_by": "root", + "command": "php artisan make:migration:safe create_login_history_table" } } } \ No newline at end of file diff --git a/database/migrations/2025_12_11_050610_create_login_history_table.php b/database/migrations/2025_12_11_050610_create_login_history_table.php new file mode 100755 index 000000000..dc359f008 --- /dev/null +++ b/database/migrations/2025_12_11_050610_create_login_history_table.php @@ -0,0 +1,35 @@ +