diff --git a/public_html/account/index.php b/public_html/account/index.php
index 726b237..65f324e 100644
--- a/public_html/account/index.php
+++ b/public_html/account/index.php
@@ -122,6 +122,12 @@ if ($initials === '') {
ID Sesión= htmlspecialchars(substr(session_id(), 0, 12)) ?>…
Org. activa= htmlspecialchars($activeOrganization ?: '–') ?>
Autenticación= empty($authData['google_auth']) ? 'Contraseña' : 'Google' ?>
+
+
Sesión iniciada= htmlspecialchars(date('d/m/Y H:i', $_SESSION['session_created'])) ?>
+
+
+
Última actividad= htmlspecialchars(date('d/m/Y H:i', $_SESSION['last_reload_time'])) ?>
+
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 3/4] 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];
+}
?>
@@ -133,6 +185,47 @@ if ($initials === '') {