Add Dispositivos conectados (connected devices) session tracking
Co-authored-by: naielv <109038805+naielv@users.noreply.github.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
17
public_html/_incl/migrations/004_user_sessions.sql
Normal file
17
public_html/_incl/migrations/004_user_sessions.sql
Normal 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);
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'] ?: '–') ?>
|
||||||
|
·
|
||||||
|
Inicio: <?= htmlspecialchars(substr($sess['created_at'], 0, 16)) ?>
|
||||||
|
·
|
||||||
|
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"; ?>
|
||||||
|
|||||||
42
public_html/account/revoke_session.php
Normal file
42
public_html/account/revoke_session.php
Normal 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();
|
||||||
Reference in New Issue
Block a user