Merge pull request #17 from Axia4/copilot/add-session-management-login

Add proper session management: secure cookies, CSRF, connected devices, opaque remember token
This commit is contained in:
Naiel
2026-03-07 20:43:08 +01:00
committed by GitHub
9 changed files with 366 additions and 22 deletions

View File

@@ -769,3 +769,115 @@ function init_active_centro(?array $auth_data = null): void
{
init_active_org($auth_data);
}
// ── User session helpers (Dispositivos conectados) ────────────────────────────
/**
* Register a new session record in user_sessions.
* $remember_token_hash is the SHA-256 hash of the raw remember-me cookie value.
* Stores a SHA-256 hash of the PHP session_id so the raw token never reaches the DB.
*/
function db_register_session(string $username, string $remember_token_hash = ''): void
{
$token = hash('sha256', session_id());
$ip = $_SERVER['HTTP_X_FORWARDED_FOR']
?? $_SERVER['REMOTE_ADDR']
?? '';
$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, remember_token_hash)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_token) DO UPDATE SET
last_active = datetime('now'),
remember_token_hash = COALESCE(excluded.remember_token_hash, remember_token_hash)"
)->execute([$token, strtolower($username), $ip, $ua, $remember_token_hash ?: null]);
}
/**
* Restore a session from a remember-me token hash.
* Updates the session_token to the current PHP session_id so revocation
* continues to work after the PHP session was re-created.
* Returns the session row (including username) on success, or null if not found.
*/
function db_restore_session_by_remember_token(string $token_hash): ?array
{
$pdo = db();
$stmt = $pdo->prepare(
'SELECT * FROM user_sessions WHERE remember_token_hash = ? LIMIT 1'
);
$stmt->execute([$token_hash]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
// Migrate the row to the new PHP session_id
$new_session_token = hash('sha256', session_id());
$pdo->prepare(
"UPDATE user_sessions
SET session_token = ?, last_active = datetime('now')
WHERE remember_token_hash = ?"
)->execute([$new_session_token, $token_hash]);
return $row;
}
/** 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

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

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

@@ -0,0 +1,12 @@
-- Axia4 Migration 005: Add remember_token_hash to user_sessions
-- Replaces the auth_user + auth_pass_b64 cookie pair with a secure opaque token.
-- The raw token lives only in the browser cookie; only its SHA-256 hash is stored.
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
ALTER TABLE user_sessions ADD COLUMN remember_token_hash TEXT DEFAULT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_sessions_remember
ON user_sessions (remember_token_hash)
WHERE remember_token_hash IS NOT NULL;

View File

@@ -22,24 +22,48 @@ if (str_starts_with($ua, "Axia4Auth/")) {
$_SESSION["auth_user"] = $username;
$_SESSION["auth_data"] = db_build_auth_data($row);
$_SESSION["auth_ok"] = true;
$_COOKIE["auth_user"] = $username;
$_COOKIE["auth_pass_b64"] = base64_encode($userpass);
$_SESSION["auth_external_lock"] = "header";
init_active_org($_SESSION["auth_data"]);
}
// ── Cookie-based auto-login ───────────────────────────────────────────────────
if (($_SESSION["auth_ok"] ?? false) != true
&& isset($_COOKIE["auth_user"], $_COOKIE["auth_pass_b64"])
) {
$username = $_COOKIE["auth_user"];
$userpass = base64_decode($_COOKIE["auth_pass_b64"]);
// ── Remember-token auto-login ─────────────────────────────────────────────────
// Restores the session from the opaque auth_token cookie (no password stored).
if (($_SESSION["auth_ok"] ?? false) != true && isset($_COOKIE["auth_token"])) {
$expired = ["expires" => time() - 3600, "path" => "/", "httponly" => true,
"secure" => true, "samesite" => "Lax"];
$raw_token = $_COOKIE["auth_token"];
$token_hash = hash('sha256', $raw_token);
$sess_row = db_restore_session_by_remember_token($token_hash);
if ($sess_row) {
$username = $sess_row['username'];
$row = db_get_user($username);
if ($row && password_verify($userpass, $row['password_hash'])) {
if ($row) {
$_SESSION["auth_user"] = $username;
$_SESSION["auth_data"] = db_build_auth_data($row);
$_SESSION["auth_ok"] = true;
if (empty($_SESSION["session_created"])) {
$_SESSION["session_created"] = time();
}
init_active_org($_SESSION["auth_data"]);
} else {
// User no longer exists — clear the stale cookie
setcookie("auth_token", "", $expired);
}
} else {
// Token not found (revoked or expired) — clear the stale cookie
setcookie("auth_token", "", $expired);
}
}
// ── 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();
}
}
@@ -53,6 +77,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) {
@@ -62,6 +87,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();

View File

@@ -1,4 +1,9 @@
<?php
session_start([ 'cookie_lifetime' => 604800 ]);
ini_set("session.use_only_cookies", "true");
ini_set("session.use_trans_sid", "false");
ini_set("session.use_only_cookies", "1");
ini_set("session.use_trans_sid", "0");
session_start([
'cookie_lifetime' => 604800,
'cookie_httponly' => true,
'cookie_secure' => true,
'cookie_samesite' => 'Lax',
]);

View File

@@ -98,10 +98,13 @@ if (($_GET["google_callback"] ?? "") === "1") {
$_SESSION['auth_user'] = $username;
$_SESSION['auth_data'] = db_build_auth_data($user_row);
$_SESSION['auth_ok'] = true;
$_SESSION['session_created'] = time();
init_active_org($_SESSION['auth_data']);
$remember_token = bin2hex(random_bytes(32));
$remember_token_hash = hash('sha256', $remember_token);
db_register_session($username, $remember_token_hash);
$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);
setcookie("auth_token", $remember_token, $cookie_options);
$redir = safe_redir($state["redir"] ?? "/");
@@ -139,13 +142,15 @@ if (($_GET["google"] ?? "") === "1") {
if (($_GET["logout"] ?? "") === "1") {
$redir = safe_redir($_GET["redir"] ?? "/");
$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);
setcookie("auth_token", "", $cookie_options_expired);
db_delete_session();
session_unset();
session_destroy();
header("Location: $redir");
die();
}
if (($_GET["clear_session"] ?? "") === "1") {
session_unset();
session_destroy();
$redir = safe_redir($_GET["redir"] ?? "/");
header("Location: $redir");
@@ -154,18 +159,26 @@ if (($_GET["clear_session"] ?? "") === "1") {
if (isset($_POST["user"])) {
$user = trim(strtolower($_POST["user"]));
$password = $_POST["password"];
// Validate CSRF token
$csrf_token = $_POST["_csrf"] ?? "";
if (!$csrf_token || !isset($_SESSION["login_csrf"]) || !hash_equals($_SESSION["login_csrf"], $csrf_token)) {
$_GET["_result"] = "Token de seguridad inválido. Por favor, recarga la página e inténtalo de nuevo.";
} else {
$row = db_get_user($user);
if (!$row || !isset($row["password_hash"])) {
$_GET["_result"] = "El usuario no existe.";
} elseif (password_verify($password, $row["password_hash"])) {
$remember_token = bin2hex(random_bytes(32));
$remember_token_hash = hash('sha256', $remember_token);
session_regenerate_id(true);
$_SESSION['auth_user'] = $user;
$_SESSION['auth_data'] = db_build_auth_data($row);
$_SESSION['auth_ok'] = true;
$_SESSION['session_created'] = time();
init_active_org($_SESSION['auth_data']);
db_register_session($user, $remember_token_hash);
$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);
setcookie("auth_token", $remember_token, $cookie_options);
$redir = safe_redir($_GET["redir"] ?? "/");
header("Location: $redir");
die();
@@ -173,14 +186,19 @@ if (isset($_POST["user"])) {
$_GET["_result"] = "La contraseña no es correcta.";
}
}
}
if (strval(db_get_config('installed')) !== '1') {
header("Location: /_install.php");
die();
}
if (empty($_SESSION["login_csrf"])) {
$_SESSION["login_csrf"] = bin2hex(random_bytes(32));
}
require_once "_incl/pre-body.php";
?>
<form method="post" action="?redir=<?= urlencode($_GET["redir"] ?? "/") ?>">
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($_SESSION["login_csrf"]) ?>">
<div class="card pad" style="max-width: 500px;">
<h1 style="text-align: center;">Iniciar sesión en Axia4</h1>
<div>

View File

@@ -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];
}
?>
<style>
.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:hover { background: #e8f0fe; }
.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>
<div class="account-grid">
@@ -122,11 +174,58 @@ if ($initials === '') {
<div class="info-row"><span class="label">ID Sesión</span><span style="font-family:monospace;font-size:.75rem;"><?= htmlspecialchars(substr(session_id(), 0, 12)) ?>…</span></div>
<div class="info-row"><span class="label">Org. activa</span><span><?= htmlspecialchars($activeOrganization ?: '') ?></span></div>
<div class="info-row"><span class="label">Autenticación</span><span><?= empty($authData['google_auth']) ? 'Contraseña' : 'Google' ?></span></div>
<?php if (!empty($_SESSION['session_created'])): ?>
<div class="info-row"><span class="label">Sesión iniciada</span><span><?= htmlspecialchars(date('d/m/Y H:i', $_SESSION['session_created'])) ?></span></div>
<?php endif; ?>
<?php if (!empty($_SESSION['last_reload_time'])): ?>
<div class="info-row"><span class="label">Última actividad</span><span><?= htmlspecialchars(date('d/m/Y H:i', $_SESSION['last_reload_time'])) ?></span></div>
<?php endif; ?>
<div style="margin-top:16px;">
<a href="/_incl/logout.php" class="btn btn-danger btn-sm">Cerrar sesión</a>
</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>
<?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();