diff --git a/config/auth.php b/config/auth.php index c4052b6..5ff0f16 100644 --- a/config/auth.php +++ b/config/auth.php @@ -6,6 +6,11 @@ // of the app. function require_login(): array { + if (empty($_SESSION['user_id']) && ($GLOBALS['pdo'] ?? null) instanceof PDO) { + // «Manter sessão iniciada»: antes de mandar para o login, tenta repor + // a sessão a partir do cookie (config/functions_remember.php). + remember_resume($GLOBALS['pdo']); + } if (empty($_SESSION['user_id'])) { $return = $_SERVER['REQUEST_URI'] ?? ''; $qs = $return ? ('?return_to=' . urlencode($return)) : ''; diff --git a/config/functions.php b/config/functions.php index c29a481..699c3ca 100644 --- a/config/functions.php +++ b/config/functions.php @@ -17,6 +17,7 @@ require_once __DIR__ . '/functions_audit.php'; // registo de auditoria (plat require_once __DIR__ . '/functions_trash.php'; // lixeira: soft delete + eliminação só após 30 dias require_once __DIR__ . '/functions_user.php'; // auth_login/register, password reset, user creators require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers +require_once __DIR__ . '/functions_remember.php'; // «Manter sessão iniciada» require_once __DIR__ . '/functions_throttle.php'; // per-IP attempt throttle for the auth endpoints require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner diff --git a/config/functions_remember.php b/config/functions_remember.php new file mode 100644 index 0000000..5520633 --- /dev/null +++ b/config/functions_remember.php @@ -0,0 +1,140 @@ +:". O selector (32 hex) indexa a linha; só o +// SHA-256 do validator (64 hex) fica na BD, por isso um dump da tabela não dá +// para forjar um cookie. Cada utilização gasta o validator e emite outro — +// um cookie roubado só vale até a próxima visita do dono; quando o dono volta +// com um validator que já não bate certo, é a assinatura de uma cópia em +// circulação, e todos os tokens da conta são revogados. + +const REMEMBER_COOKIE = 'fit_remember'; +const REMEMBER_DAYS = 30; + +function remember_set_cookie(?string $value, int $expires = 0): void { + if (headers_sent()) return; + setcookie(REMEMBER_COOKIE, (string) $value, [ + 'expires' => $expires, + 'path' => '/', + 'secure' => request_is_https(), + 'httponly' => true, + // Lax, não Strict: um link de fora (email) não traz um cookie Strict, + // e a pessoa via o login outra vez apesar de ter pedido para ficar. + 'samesite' => 'Lax', + ]); +} + +function remember_forget_cookie(): void { + remember_set_cookie('', time() - 3600); +} + +function remember_parse_cookie(): ?array { + $raw = (string) ($_COOKIE[REMEMBER_COOKIE] ?? ''); + if ($raw === '' || substr_count($raw, ':') !== 1) return null; + [$selector, $validator] = explode(':', $raw, 2); + if (strlen($selector) !== 32 || !ctype_xdigit($selector)) return null; + if (strlen($validator) !== 64 || !ctype_xdigit($validator)) return null; + return ['selector' => $selector, 'validator' => $validator]; +} + +// Chamar depois de um login bem sucedido, só quando a pessoa pediu para ficar. +function remember_issue(PDO $pdo, int $userId): void { + if ($userId <= 0) return; + $selector = bin2hex(random_bytes(16)); + $validator = bin2hex(random_bytes(32)); + $expires = time() + REMEMBER_DAYS * 86400; + try { + $pdo->prepare( + "INSERT INTO remember_tokens (user_id, selector, validator_hash, expires_at, last_ip, user_agent) + VALUES (?, ?, ?, ?, ?, ?)" + )->execute([ + $userId, $selector, hash('sha256', $validator), date('Y-m-d H:i:s', $expires), + client_ip(), mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + } catch (Throwable $e) { + // Um login válido nunca deve falhar por causa disto. + error_log('[remember] issue failed: ' . $e->getMessage()); + return; + } + remember_set_cookie($selector . ':' . $validator, $expires); + if (random_int(0, 19) === 0) remember_prune($pdo); +} + +// Repõe a sessão a partir do cookie, se houver um válido. Não redirecciona. +function remember_resume(PDO $pdo): void { + if (!empty($_SESSION['user_id'])) return; + $cookie = remember_parse_cookie(); + if ($cookie === null) return; + + try { + $st = $pdo->prepare("SELECT * FROM remember_tokens WHERE selector = ? LIMIT 1"); + $st->execute([$cookie['selector']]); + $row = $st->fetch(PDO::FETCH_ASSOC) ?: null; + } catch (Throwable $e) { + error_log('[remember] lookup failed: ' . $e->getMessage()); + return; + } + + if ($row === null) { + remember_forget_cookie(); + return; + } + + if (!hash_equals((string) $row['validator_hash'], hash('sha256', $cookie['validator']))) { + error_log('[remember] validator mismatch for user ' . (int) $row['user_id'] . ' — a revogar todos os tokens da conta'); + remember_forget_user($pdo, (int) $row['user_id']); + remember_forget_cookie(); + return; + } + + if (strtotime((string) $row['expires_at']) <= time()) { + remember_delete($pdo, (string) $row['selector']); + remember_forget_cookie(); + return; + } + + $st = $pdo->prepare("SELECT * FROM users WHERE id = ? AND is_active = 1 LIMIT 1"); + $st->execute([(int) $row['user_id']]); + $user = $st->fetch(PDO::FETCH_ASSOC) ?: null; + if ($user === null) { + remember_forget_user($pdo, (int) $row['user_id']); + remember_forget_cookie(); + return; + } + + auth_set_session($user); + $_SESSION['_remembered'] = true; + remember_rotate($pdo, (string) $row['selector']); +} + +function remember_rotate(PDO $pdo, string $selector): void { + $validator = bin2hex(random_bytes(32)); + $expires = time() + REMEMBER_DAYS * 86400; + try { + $pdo->prepare("UPDATE remember_tokens SET validator_hash = ?, expires_at = ?, last_ip = ? WHERE selector = ?") + ->execute([hash('sha256', $validator), date('Y-m-d H:i:s', $expires), client_ip(), $selector]); + } catch (Throwable $e) { + error_log('[remember] rotate failed: ' . $e->getMessage()); + return; + } + remember_set_cookie($selector . ':' . $validator, $expires); +} + +function remember_delete(PDO $pdo, string $selector): void { + try { $pdo->prepare("DELETE FROM remember_tokens WHERE selector = ?")->execute([$selector]); } + catch (Throwable $e) { error_log('[remember] delete failed: ' . $e->getMessage()); } +} + +// Chamar no logout e ao mudar a palavra-passe — corta qualquer cookie roubado. +function remember_forget_user(PDO $pdo, int $userId): void { + try { $pdo->prepare("DELETE FROM remember_tokens WHERE user_id = ?")->execute([$userId]); } + catch (Throwable $e) { error_log('[remember] forget_user failed: ' . $e->getMessage()); } +} + +function remember_prune(PDO $pdo): void { + try { $pdo->exec("DELETE FROM remember_tokens WHERE expires_at < NOW()"); } + catch (Throwable $e) { /* accessório */ } +} diff --git a/config/functions_user.php b/config/functions_user.php index baa3e33..dec0840 100644 --- a/config/functions_user.php +++ b/config/functions_user.php @@ -75,6 +75,9 @@ function auth_login(PDO $pdo, string $email, string $password): ?array { function auth_set_password(PDO $pdo, int $userId, string $password): void { $pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?") ->execute([password_hash($password, PASSWORD_DEFAULT), $userId]); + // Uma password nova invalida qualquer «manter sessão iniciada» antigo — + // sem isto, um cookie roubado antes da mudança continuava a entrar depois. + remember_forget_user($pdo, $userId); $self = (int) ($_SESSION['user_id'] ?? 0) === $userId; audit($self ? 'auth.password_change' : 'user.password_set', [ 'target_type' => 'user', 'target_id' => $userId, diff --git a/database/migration_20260925_remember_me.sql b/database/migration_20260925_remember_me.sql new file mode 100644 index 0000000..ffe5cd9 --- /dev/null +++ b/database/migration_20260925_remember_me.sql @@ -0,0 +1,27 @@ +-- ─── «Manter sessão iniciada» ──────────────────────────────────────────────── +-- Auditoria 2026-09-24, "Sem «Manter sessão iniciada»" (Médio). A sessão do +-- PHP morre com o browser e o garbage collector apaga-a do servidor bem antes +-- disso — esticar o cookie da sessão não chega, é preciso estado próprio. +-- +-- Selector/validator (o mesmo desenho do platform-tools functions_remember.php, +-- implementado localmente porque esta app tem a sua própria autenticação — ver +-- config/functions_remember.php): selector em claro e UNIQUE (é o índice da +-- consulta), só o hash SHA-256 do validator fica guardado. Quem levar um dump +-- desta tabela não consegue forjar um cookie a partir dela. +-- +-- Safe to re-run. + +CREATE TABLE IF NOT EXISTS `remember_tokens` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `selector` CHAR(32) NOT NULL, + `validator_hash` CHAR(64) NOT NULL, + `expires_at` DATETIME NOT NULL, + `last_ip` VARCHAR(45) NOT NULL DEFAULT '', + `user_agent` VARCHAR(255) NOT NULL DEFAULT '', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_remember_selector` (`selector`), + KEY `idx_remember_user` (`user_id`), + KEY `idx_remember_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/views/auth/login.php b/views/auth/login.php index fb68869..75ece4d 100644 --- a/views/auth/login.php +++ b/views/auth/login.php @@ -57,6 +57,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $user = auth_login($pdo, $_POST['email'] ?? '', $_POST['password'] ?? ''); if ($user) { auth_set_session($user); + if (!empty($_POST['remember'])) { + remember_issue($pdo, (int) $user['id']); + } audit('auth.login', ['target_type' => 'user', 'target_id' => (int) $user['id'], 'target_label' => (string) $user['name']]); header('Location: ' . base_url($destination($user['role']))); exit; @@ -122,6 +125,10 @@ include __DIR__ . '/../../includes/auth_header.php'; Esqueceu a palavra-passe? +
+ + +
diff --git a/views/auth/logout.php b/views/auth/logout.php index 0f78f71..71574ab 100644 --- a/views/auth/logout.php +++ b/views/auth/logout.php @@ -7,7 +7,12 @@ require __DIR__ . '/../../config/functions.php'; if (!empty($_SESSION['user_id'])) { audit('auth.logout', ['target_type' => 'user', 'target_id' => (int) $_SESSION['user_id']]); + // Só o token deste browser, não toda a conta: sair aqui não deve expulsar + // a pessoa de outro dispositivo onde pediu para ficar. + $cookie = remember_parse_cookie(); + if ($cookie !== null) remember_delete($pdo, $cookie['selector']); } +remember_forget_cookie(); $_SESSION = []; // Drop the cookie too, so the browser doesn't keep presenting a dead id. if (ini_get('session.use_cookies')) {