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:
@@ -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 */
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -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 */
|
||||
|
||||
/**
|
||||
|
||||
225
app/RSpade/Core/Session/Login_History.php
Executable file
225
app/RSpade/Core/Session/Login_History.php
Executable 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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
173
app/RSpade/Core/Session/User_Agent.php
Executable file
173
app/RSpade/Core/Session/User_Agent.php
Executable 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'];
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user