From 868b8477e0b05d353c23ad0014e4434c6e879b82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:37:23 +0000 Subject: [PATCH] Add Dispositivos conectados (connected devices) session tracking Co-authored-by: naielv <109038805+naielv@users.noreply.github.com> --- public_html/_incl/db.php | 85 +++++++++++++++++ public_html/_incl/logout.php | 2 + .../_incl/migrations/004_user_sessions.sql | 17 ++++ public_html/_incl/tools.auth.php | 15 +++ public_html/_login.php | 3 + public_html/account/index.php | 93 +++++++++++++++++++ public_html/account/revoke_session.php | 42 +++++++++ 7 files changed, 257 insertions(+) create mode 100644 public_html/_incl/migrations/004_user_sessions.sql create mode 100644 public_html/account/revoke_session.php diff --git a/public_html/_incl/db.php b/public_html/_incl/db.php index 574c8b9..e728caa 100644 --- a/public_html/_incl/db.php +++ b/public_html/_incl/db.php @@ -769,3 +769,88 @@ function init_active_centro(?array $auth_data = null): void { init_active_org($auth_data); } + +// ── User session helpers (Dispositivos conectados) ──────────────────────────── + +/** + * Register or refresh a session record in user_sessions. + * Stores a SHA-256 hash of the PHP session_id so the raw token never reaches the DB. + */ +function db_register_session(string $username): void +{ + $token = hash('sha256', session_id()); + $ip = $_SERVER['HTTP_X_FORWARDED_FOR'] + ?? $_SERVER['REMOTE_ADDR'] + ?? ''; + // Only keep the first IP if X-Forwarded-For contains a list + $ip = trim(explode(',', $ip)[0]); + $ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512); + + db()->prepare( + "INSERT INTO user_sessions (session_token, username, ip_address, user_agent) + VALUES (?, ?, ?, ?) + ON CONFLICT(session_token) DO UPDATE SET + last_active = datetime('now'), + username = excluded.username" + )->execute([$token, strtolower($username), $ip, $ua]); +} + +/** Update last_active for the current session. */ +function db_touch_session(): void +{ + $token = hash('sha256', session_id()); + db()->prepare( + "UPDATE user_sessions SET last_active = datetime('now') WHERE session_token = ?" + )->execute([$token]); +} + +/** Delete the current session record from the DB. */ +function db_delete_session(): void +{ + $token = hash('sha256', session_id()); + db()->prepare('DELETE FROM user_sessions WHERE session_token = ?')->execute([$token]); +} + +/** + * Delete a specific session by token for a given user. + * Enforces that the session belongs to the requesting user. + */ +function db_revoke_session(string $token, string $username): void +{ + db()->prepare('DELETE FROM user_sessions WHERE session_token = ? AND username = ?') + ->execute([$token, strtolower($username)]); +} + +/** Delete all session records for a user (e.g. on password change or account wipe). */ +function db_delete_user_sessions(string $username): void +{ + db()->prepare('DELETE FROM user_sessions WHERE username = ?') + ->execute([strtolower($username)]); +} + +/** Return all session rows for a user, newest first. */ +function db_get_user_sessions(string $username): array +{ + $stmt = db()->prepare( + 'SELECT session_token, ip_address, user_agent, created_at, last_active + FROM user_sessions + WHERE username = ? + ORDER BY last_active DESC' + ); + $stmt->execute([strtolower($username)]); + return $stmt->fetchAll(); +} + +/** + * Check whether the current PHP session has a valid record in user_sessions. + * Returns false if the session was revoked or was never registered. + */ +function db_session_is_valid(string $username): bool +{ + $token = hash('sha256', session_id()); + $stmt = db()->prepare( + 'SELECT 1 FROM user_sessions WHERE session_token = ? AND username = ? LIMIT 1' + ); + $stmt->execute([$token, strtolower($username)]); + return $stmt->fetchColumn() !== false; +} diff --git a/public_html/_incl/logout.php b/public_html/_incl/logout.php index 5c1cc59..dcf590c 100644 --- a/public_html/_incl/logout.php +++ b/public_html/_incl/logout.php @@ -1,11 +1,13 @@ time() - 3600, "path" => "/", "httponly" => true, "secure" => true, "samesite" => "Lax"]; setcookie("auth_user", "", $cookie_options_expired); setcookie("auth_pass_b64", "", $cookie_options_expired); +db_delete_session(); session_unset(); session_destroy(); header("Location: $redir"); diff --git a/public_html/_incl/migrations/004_user_sessions.sql b/public_html/_incl/migrations/004_user_sessions.sql new file mode 100644 index 0000000..202a280 --- /dev/null +++ b/public_html/_incl/migrations/004_user_sessions.sql @@ -0,0 +1,17 @@ +-- Axia4 Migration 004: User Sessions (Dispositivos conectados) +-- Tracks active authenticated sessions so users can see and revoke connected devices. + +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS user_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_token TEXT UNIQUE NOT NULL, -- SHA-256 hash of the PHP session_id() + username TEXT NOT NULL, + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_active TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_username ON user_sessions (username); diff --git a/public_html/_incl/tools.auth.php b/public_html/_incl/tools.auth.php index 55d9e81..eee350d 100644 --- a/public_html/_incl/tools.auth.php +++ b/public_html/_incl/tools.auth.php @@ -43,6 +43,19 @@ if (($_SESSION["auth_ok"] ?? false) != true $_SESSION["session_created"] = time(); } init_active_org($_SESSION["auth_data"]); + db_register_session($username); + } +} + +// ── Validate session is still active in user_sessions (enables revocation) ─── +if (!empty($_SESSION["auth_ok"]) && !empty($_SESSION["auth_user"]) + && empty($_SESSION["auth_external_lock"]) +) { + if (!db_session_is_valid($_SESSION["auth_user"])) { + session_unset(); + session_destroy(); + header("Location: /_login.php?_result=" . urlencode("Tu sesión fue revocada. Inicia sesión de nuevo.")); + die(); } } @@ -56,6 +69,7 @@ if (!empty($_SESSION["auth_ok"]) && !empty($_SESSION["auth_user"])) { init_active_org($_SESSION["auth_data"]); } $_SESSION["last_reload_time"] = time(); + db_touch_session(); } elseif ($load_mode !== "never") { $last = $_SESSION["last_reload_time"] ?? 0; if (time() - $last > 300) { @@ -65,6 +79,7 @@ if (!empty($_SESSION["auth_ok"]) && !empty($_SESSION["auth_user"])) { init_active_org($_SESSION["auth_data"]); } $_SESSION["last_reload_time"] = time(); + db_touch_session(); } if (!isset($_SESSION["last_reload_time"])) { $_SESSION["last_reload_time"] = time(); diff --git a/public_html/_login.php b/public_html/_login.php index e4629e1..a6c2eb4 100644 --- a/public_html/_login.php +++ b/public_html/_login.php @@ -100,6 +100,7 @@ if (($_GET["google_callback"] ?? "") === "1") { $_SESSION['auth_ok'] = true; $_SESSION['session_created'] = time(); init_active_org($_SESSION['auth_data']); + db_register_session($username); $cookie_options = ["expires" => time() + (86400 * 30), "path" => "/", "httponly" => true, "secure" => true, "samesite" => "Lax"]; setcookie("auth_user", $username, $cookie_options); setcookie("auth_pass_b64", base64_encode($password), $cookie_options); @@ -142,6 +143,7 @@ if (($_GET["logout"] ?? "") === "1") { $cookie_options_expired = ["expires" => time() - 3600, "path" => "/", "httponly" => true, "secure" => true, "samesite" => "Lax"]; setcookie("auth_user", "", $cookie_options_expired); setcookie("auth_pass_b64", "", $cookie_options_expired); + db_delete_session(); session_unset(); session_destroy(); header("Location: $redir"); @@ -172,6 +174,7 @@ if (isset($_POST["user"])) { $_SESSION['auth_ok'] = true; $_SESSION['session_created'] = time(); init_active_org($_SESSION['auth_data']); + db_register_session($user); $cookie_options = ["expires" => time() + (86400 * 30), "path" => "/", "httponly" => true, "secure" => true, "samesite" => "Lax"]; setcookie("auth_user", $user, $cookie_options); setcookie("auth_pass_b64", base64_encode($password), $cookie_options); diff --git a/public_html/account/index.php b/public_html/account/index.php index 65f324e..4745728 100644 --- a/public_html/account/index.php +++ b/public_html/account/index.php @@ -23,6 +23,51 @@ $initials = mb_strtoupper(mb_substr($parts[0] ?? '', 0, 1) . mb_substr($parts[1] if ($initials === '') { $initials = '?'; } + +// Connected devices +$connectedSessions = db_get_user_sessions($username); +$currentToken = hash('sha256', session_id()); + +/** + * Parse a raw User-Agent string into a human-readable browser + OS label. + * Returns an array ['browser' => string, 'os' => string, 'icon' => string]. + */ +function parse_ua(string $ua): array +{ + // OS + $os = 'Desconocido'; + if (stripos($ua, 'Android') !== false) $os = 'Android'; + elseif (stripos($ua, 'iPhone') !== false + || stripos($ua, 'iPad') !== false) $os = 'iOS'; + elseif (stripos($ua, 'Windows') !== false) $os = 'Windows'; + elseif (stripos($ua, 'Macintosh') !== false + || stripos($ua, 'Mac OS') !== false) $os = 'macOS'; + elseif (stripos($ua, 'Linux') !== false) $os = 'Linux'; + elseif (stripos($ua, 'CrOS') !== false) $os = 'ChromeOS'; + + // Browser + $browser = 'Desconocido'; + if (str_starts_with($ua, 'Axia4Auth/')) $browser = 'Axia4 App'; + elseif (stripos($ua, 'Edg/') !== false) $browser = 'Edge'; + elseif (stripos($ua, 'OPR/') !== false + || stripos($ua, 'Opera') !== false) $browser = 'Opera'; + elseif (stripos($ua, 'Chrome') !== false) $browser = 'Chrome'; + elseif (stripos($ua, 'Firefox') !== false) $browser = 'Firefox'; + elseif (stripos($ua, 'Safari') !== false) $browser = 'Safari'; + + // Emoji icon for a quick visual cue + $icon = match ($browser) { + 'Chrome' => '🌐', + 'Firefox' => '🦊', + 'Safari' => '🧭', + 'Edge' => '🔷', + 'Opera' => '🔴', + 'Axia4 App' => '📱', + default => '💻', + }; + + return ['browser' => $browser, 'os' => $os, 'icon' => $icon]; +} ?>