Add login history tracking and session management features

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-12-11 05:26:44 +00:00
parent dd6c17f923
commit 18928c8678
10 changed files with 881 additions and 142 deletions

View File

@@ -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 */
/**

View File

@@ -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 */
// =========================================================================

View File

@@ -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 */
/**

View File

@@ -0,0 +1,225 @@
<?php
namespace App\RSpade\Core\Session;
use Illuminate\Support\Facades\DB;
use App\RSpade\Core\Session\User_Agent;
/**
* Login History - Static service for recording and retrieving login history
*
* This class provides methods for:
* - Recording successful and failed login attempts
* - Retrieving login history for a user
* - Counting failed attempts for rate limiting
*
* Similar to Session class design - static methods for developers to interact
* with system tables without needing to know the underlying table structure.
*
* Table: _login_history (system table, prefixed with underscore)
*/
class Login_History
{
// Status constants
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED_PASSWORD = 'failed_password';
public const STATUS_FAILED_2FA = 'failed_2fa';
public const STATUS_FAILED_LOCKED = 'failed_locked';
public const STATUS_FAILED_DISABLED = 'failed_disabled';
public const STATUS_FAILED_NOT_FOUND = 'failed_not_found';
/**
* Record a successful login
*
* @param int $login_user_id The authenticated user's ID
* @param string $email The email used to log in
* @return void
*/
public static function record_success(int $login_user_id, string $email): void
{
self::_record($login_user_id, $email, self::STATUS_SUCCESS);
}
/**
* Record a failed login attempt
*
* @param string $email The email attempted
* @param string $status One of the STATUS_FAILED_* constants
* @param string|null $reason Optional failure reason details
* @param int|null $login_user_id Optional user ID if known
* @return void
*/
public static function record_failure(
string $email,
string $status = self::STATUS_FAILED_PASSWORD,
?string $reason = null,
?int $login_user_id = null
): void {
self::_record($login_user_id, $email, $status, $reason);
}
/**
* Internal method to record a login history entry
*
* @param int|null $login_user_id
* @param string $email
* @param string $status
* @param string|null $reason
* @return void
*/
private static function _record(
?int $login_user_id,
string $email,
string $status,
?string $reason = null
): void {
DB::table('_login_history')->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)),
};
}
}

View File

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

View File

@@ -0,0 +1,173 @@
<?php
namespace App\RSpade\Core\Session;
/**
* User Agent Parser - Static helper for parsing user agent strings
*
* Provides simple parsing of user agent strings into human-readable
* browser, OS, and device type information.
*
* This is intentionally simple and doesn't aim to be comprehensive.
* For more detailed parsing, consider a dedicated library like
* WhichBrowser or ua-parser.
*/
class User_Agent
{
/**
* Parse a user agent string into components
*
* @param string|null $user_agent
* @return array{browser: string, os: string, device: string, summary: string}
*/
public static function parse(?string $user_agent): array
{
if (empty($user_agent)) {
return [
'browser' => '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'];
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up()
{
DB::statement("
CREATE TABLE _login_history (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
login_user_id BIGINT NULL,
email_attempted VARCHAR(255) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
user_agent VARCHAR(512) NULL,
location_city VARCHAR(100) NULL,
location_region VARCHAR(100) NULL,
location_country VARCHAR(2) NULL,
status VARCHAR(32) NOT NULL,
failure_reason VARCHAR(255) NULL,
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX _login_history_login_user_id_idx (login_user_id),
INDEX _login_history_email_attempted_idx (email_attempted),
INDEX _login_history_created_at_idx (created_at),
INDEX _login_history_status_idx (status),
INDEX _login_history_ip_address_idx (ip_address)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
};