Add Dispositivos conectados (connected devices) session tracking

Co-authored-by: naielv <109038805+naielv@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-07 19:37:23 +00:00
parent c21dfad437
commit 868b8477e0
7 changed files with 257 additions and 0 deletions

View File

@@ -769,3 +769,88 @@ function init_active_centro(?array $auth_data = null): void
{ {
init_active_org($auth_data); 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;
}

View File

@@ -1,11 +1,13 @@
<?php <?php
require_once "tools.session.php"; require_once "tools.session.php";
require_once "tools.security.php"; require_once "tools.security.php";
require_once "db.php";
$redir = safe_redir($_GET["redir"] ?? "/"); $redir = safe_redir($_GET["redir"] ?? "/");
$cookie_options_expired = ["expires" => time() - 3600, "path" => "/", "httponly" => true, "secure" => true, "samesite" => "Lax"]; $cookie_options_expired = ["expires" => time() - 3600, "path" => "/", "httponly" => true, "secure" => true, "samesite" => "Lax"];
setcookie("auth_user", "", $cookie_options_expired); setcookie("auth_user", "", $cookie_options_expired);
setcookie("auth_pass_b64", "", $cookie_options_expired); setcookie("auth_pass_b64", "", $cookie_options_expired);
db_delete_session();
session_unset(); session_unset();
session_destroy(); session_destroy();
header("Location: $redir"); header("Location: $redir");

View File

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

View File

@@ -43,6 +43,19 @@ if (($_SESSION["auth_ok"] ?? false) != true
$_SESSION["session_created"] = time(); $_SESSION["session_created"] = time();
} }
init_active_org($_SESSION["auth_data"]); 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"]); init_active_org($_SESSION["auth_data"]);
} }
$_SESSION["last_reload_time"] = time(); $_SESSION["last_reload_time"] = time();
db_touch_session();
} elseif ($load_mode !== "never") { } elseif ($load_mode !== "never") {
$last = $_SESSION["last_reload_time"] ?? 0; $last = $_SESSION["last_reload_time"] ?? 0;
if (time() - $last > 300) { if (time() - $last > 300) {
@@ -65,6 +79,7 @@ if (!empty($_SESSION["auth_ok"]) && !empty($_SESSION["auth_user"])) {
init_active_org($_SESSION["auth_data"]); init_active_org($_SESSION["auth_data"]);
} }
$_SESSION["last_reload_time"] = time(); $_SESSION["last_reload_time"] = time();
db_touch_session();
} }
if (!isset($_SESSION["last_reload_time"])) { if (!isset($_SESSION["last_reload_time"])) {
$_SESSION["last_reload_time"] = time(); $_SESSION["last_reload_time"] = time();

View File

@@ -100,6 +100,7 @@ if (($_GET["google_callback"] ?? "") === "1") {
$_SESSION['auth_ok'] = true; $_SESSION['auth_ok'] = true;
$_SESSION['session_created'] = time(); $_SESSION['session_created'] = time();
init_active_org($_SESSION['auth_data']); init_active_org($_SESSION['auth_data']);
db_register_session($username);
$cookie_options = ["expires" => time() + (86400 * 30), "path" => "/", "httponly" => true, "secure" => true, "samesite" => "Lax"]; $cookie_options = ["expires" => time() + (86400 * 30), "path" => "/", "httponly" => true, "secure" => true, "samesite" => "Lax"];
setcookie("auth_user", $username, $cookie_options); setcookie("auth_user", $username, $cookie_options);
setcookie("auth_pass_b64", base64_encode($password), $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"]; $cookie_options_expired = ["expires" => time() - 3600, "path" => "/", "httponly" => true, "secure" => true, "samesite" => "Lax"];
setcookie("auth_user", "", $cookie_options_expired); setcookie("auth_user", "", $cookie_options_expired);
setcookie("auth_pass_b64", "", $cookie_options_expired); setcookie("auth_pass_b64", "", $cookie_options_expired);
db_delete_session();
session_unset(); session_unset();
session_destroy(); session_destroy();
header("Location: $redir"); header("Location: $redir");
@@ -172,6 +174,7 @@ if (isset($_POST["user"])) {
$_SESSION['auth_ok'] = true; $_SESSION['auth_ok'] = true;
$_SESSION['session_created'] = time(); $_SESSION['session_created'] = time();
init_active_org($_SESSION['auth_data']); init_active_org($_SESSION['auth_data']);
db_register_session($user);
$cookie_options = ["expires" => time() + (86400 * 30), "path" => "/", "httponly" => true, "secure" => true, "samesite" => "Lax"]; $cookie_options = ["expires" => time() + (86400 * 30), "path" => "/", "httponly" => true, "secure" => true, "samesite" => "Lax"];
setcookie("auth_user", $user, $cookie_options); setcookie("auth_user", $user, $cookie_options);
setcookie("auth_pass_b64", base64_encode($password), $cookie_options); setcookie("auth_pass_b64", base64_encode($password), $cookie_options);

View File

@@ -23,6 +23,51 @@ $initials = mb_strtoupper(mb_substr($parts[0] ?? '', 0, 1) . mb_substr($parts[1]
if ($initials === '') { if ($initials === '') {
$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];
}
?> ?>
<style> <style>
.account-grid { display: flex; flex-wrap: wrap; gap: 16px; padding: 16px; } .account-grid { display: flex; flex-wrap: wrap; gap: 16px; padding: 16px; }
@@ -38,6 +83,13 @@ if ($initials === '') {
.tenant-btn { display: block; width: 100%; text-align: left; padding: 8px 12px; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 8px; background: #f8f9fa; cursor: pointer; font-size: .9rem; transition: background .15s; } .tenant-btn { display: block; width: 100%; text-align: left; padding: 8px 12px; border: 1px solid #e0e0e0; border-radius: 8px; margin-bottom: 8px; background: #f8f9fa; cursor: pointer; font-size: .9rem; transition: background .15s; }
.tenant-btn:hover { background: #e8f0fe; } .tenant-btn:hover { background: #e8f0fe; }
.tenant-btn.active-tenant { border-color: var(--gw-blue, #1a73e8); background: #e8f0fe; font-weight: 600; } .tenant-btn.active-tenant { border-color: var(--gw-blue, #1a73e8); background: #e8f0fe; font-weight: 600; }
.device-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #f1f3f4; }
.device-row:last-child { border-bottom: none; }
.device-icon { font-size: 1.6rem; flex-shrink: 0; }
.device-info { flex: 1; min-width: 0; }
.device-name { font-weight: 600; font-size: .9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.device-meta { font-size: .78rem; color: var(--gw-text-secondary, #5f6368); margin-top: 2px; }
.device-current { font-size: .75rem; color: #137333; font-weight: 600; background: #e6f4ea; border-radius: 99px; padding: 1px 8px; }
</style> </style>
<div class="account-grid"> <div class="account-grid">
@@ -133,6 +185,47 @@ if ($initials === '') {
</div> </div>
</div> </div>
<!-- Connected Devices Card -->
<?php if (!empty($connectedSessions)): ?>
<div class="account-card" style="flex-basis:100%;">
<h2>Dispositivos conectados</h2>
<?php foreach ($connectedSessions as $sess):
$isCurrent = hash_equals($currentToken, $sess['session_token']);
$ua_info = parse_ua($sess['user_agent']);
$label = $ua_info['browser'] . ' — ' . $ua_info['os'];
?>
<div class="device-row">
<div class="device-icon"><?= $ua_info['icon'] ?></div>
<div class="device-info">
<div class="device-name">
<?= htmlspecialchars($label) ?>
<?php if ($isCurrent): ?>
<span class="device-current">Este dispositivo</span>
<?php endif; ?>
</div>
<div class="device-meta">
IP: <?= htmlspecialchars($sess['ip_address'] ?: '') ?>
&nbsp;·&nbsp;
Inicio: <?= htmlspecialchars(substr($sess['created_at'], 0, 16)) ?>
&nbsp;·&nbsp;
Activo: <?= htmlspecialchars(substr($sess['last_active'], 0, 16)) ?>
</div>
</div>
<?php if (!$isCurrent): ?>
<form method="post" action="/account/revoke_session.php" style="margin:0;">
<input type="hidden" name="token" value="<?= htmlspecialchars($sess['session_token']) ?>">
<input type="hidden" name="redir" value="/account/">
<button type="submit" class="btn btn-outline-danger btn-sm"
onclick="return confirm('¿Cerrar sesión en este dispositivo?')">
Revocar
</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div> </div>
<?php require_once "_incl/post-body.php"; ?> <?php require_once "_incl/post-body.php"; ?>

View File

@@ -0,0 +1,42 @@
<?php
/**
* Revoke a connected device session.
* POST-only. Requires the user to be authenticated.
* Accepts: token (session_token hash), redir (safe redirect URL).
*/
require_once "_incl/auth_redir.php";
require_once "../_incl/db.php";
require_once "../_incl/tools.security.php";
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('HTTP/1.1 405 Method Not Allowed');
die();
}
$username = $_SESSION['auth_user'] ?? '';
if ($username === '') {
header('HTTP/1.1 401 Unauthorized');
die();
}
$token = preg_replace('/[^a-f0-9]/', '', strtolower($_POST['token'] ?? ''));
$redir = safe_redir($_POST['redir'] ?? '/account/');
if ($token === '') {
header("Location: $redir");
die();
}
$current_token = hash('sha256', session_id());
// Prevent revoking the current session through this endpoint
// (users should use the regular logout for that)
if (hash_equals($current_token, $token)) {
header("Location: $redir");
die();
}
db_revoke_session($token, $username);
header("Location: $redir");
die();