:`. // // selector procura a linha. Está em claro na base de dados e é UNIQUE, por // isso a consulta é um índice, não uma varredura. // validator prova que o cookie é mesmo aquele. Na base de dados só existe o // SHA-256 dele. // // A razão para os separar é a mesma que faz guardar palavras-passe com hash: // quem leve um dump da tabela não consegue forjar um cookie a partir dele. Um // esquema de token único obrigaria a procurar pelo próprio segredo — ou o // segredo fica em claro na base de dados, ou não há forma de o indexar. A // comparação é feita com hash_equals(), não com ===, para não dar o resultado // por temporização. // // ROTAÇÃO E DETECÇÃO DE ROUBO // // Cada utilização gasta o validator e emite outro. Um cookie roubado vale, por // isso, até à próxima visita do dono — e quando o dono volta, apresenta um // validator que já não bate certo com um selector que existe. Isso não acontece // por acidente: é a assinatura de uma cópia em circulação. Nesse caso apagam-se // TODOS os tokens da conta, o que corta o atacante e obriga o dono a entrar de // novo. Preferimos o incómodo à sessão silenciosamente partilhada. // // O QUE ISTO NÃO É // // Não é uma sessão privilegiada. `auth_remember_active()` diz se a sessão // actual nasceu de um cookie em vez de uma palavra-passe escrita agora, para // que uma operação sensível (mudar palavra-passe, apagar conta) possa exigir // autenticação fresca. Cabe a cada página decidir — este ficheiro só regista // a proveniência. require_once __DIR__ . '/functions_auth.php'; require_once __DIR__ . '/functions_client_ip.php'; /** * Nome do cookie. Prefixado por instalação para que dois sites no mesmo domínio * (fotografia e formação são ambos *.andmore.pt) não se pisem: o cookie de um * chegaria ao outro, que procuraria o selector na SUA tabela e não o * encontraria — um logout sem explicação a cada troca de separador. */ function auth_remember_cookie_name(): string { // Uma constante do site ganha ao .env: é como um site que já tinha cookie // próprio antes deste módulo (o dance, `dsa_remember`) passa a usá-lo sem // deitar fora as sessões lembradas que já existiam. if (defined('AUTH_REMEMBER_COOKIE') && (string) AUTH_REMEMBER_COOKIE !== '') { return (string) AUTH_REMEMBER_COOKIE; } $name = function_exists('env') ? (string) env('AUTH_REMEMBER_COOKIE', '') : ''; return $name !== '' ? $name : 'am_remember'; } /** Validade, em dias. Trinta é o costume; abaixo de 1 desliga o mecanismo. */ function auth_remember_days(): int { $d = function_exists('env') ? (int) env('AUTH_REMEMBER_DAYS', 30) : 30; return $d > 0 ? $d : 30; } function auth_remember_ensure_table(PDO $pdo): void { // Sem chave estrangeira para users: o nome da tabela de contas varia de site // para site (é o que auth_schema() existe para resolver) e o tipo da coluna // id também — INT UNSIGNED nuns, BIGINT noutros. Uma FK aqui seria uma // promessa que este ficheiro não pode cumprir em todo o lado. $pdo->exec( "CREATE TABLE IF NOT EXISTS `auth_remember_tokens` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` BIGINT UNSIGNED NOT NULL, `selector` CHAR(32) NOT NULL, `validator_hash` CHAR(64) NOT NULL, `expires_at` DATETIME NOT NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `last_used_at` DATETIME NULL, `last_ip` VARCHAR(45) NULL, `user_agent` VARCHAR(255) NULL, PRIMARY KEY (`id`), UNIQUE KEY `uq_remember_selector` (`selector`), KEY `idx_remember_user` (`user_id`), KEY `idx_remember_expires` (`expires_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ); } /** Escreve (ou apaga, com $value = null) o cookie com os parâmetros certos. */ function auth_remember_set_cookie(?string $value, int $expires = 0): void { if (headers_sent()) return; // nada a fazer, e um warning não ajudaria $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); setcookie(auth_remember_cookie_name(), (string) $value, [ 'expires' => $expires, 'path' => '/', 'secure' => $https, 'httponly' => true, // Lax e não Strict: com Strict, quem chega ao site por um link de fora // (um email com a ligação para a plataforma) não traz o cookie e vê o // formulário de login — exactamente a situação que isto devia evitar. 'samesite' => 'Lax', ]); } /** Parte o cookie nos seus dois campos. null se estiver ausente ou deformado. */ function auth_remember_parse_cookie(): ?array { $raw = (string) ($_COOKIE[auth_remember_cookie_name()] ?? ''); 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]; } /** * Emite um token novo para $userId e manda o cookie. * * Chamado depois de uma autenticação bem sucedida, e só quando o utilizador * pediu para ser lembrado. */ function auth_remember_issue(PDO $pdo, int $userId): void { if ($userId <= 0) return; auth_remember_ensure_table($pdo); $selector = bin2hex(random_bytes(16)); // 32 hex $validator = bin2hex(random_bytes(32)); // 64 hex $expires = time() + auth_remember_days() * 86400; try { $pdo->prepare( "INSERT INTO `auth_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), platform_client_ip(), mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), ]); } catch (Throwable $e) { // Um login válido nunca deve falhar porque o "lembrar-me" não coube. error_log('[auth/remember] issue failed: ' . $e->getMessage()); return; } auth_remember_set_cookie($selector . ':' . $validator, $expires); // Barato, e evita que a tabela cresça para sempre numa instalação sem cron. auth_remember_prune($pdo); } /** * Tenta repor a sessão a partir do cookie. * * Devolve a linha do utilizador (e deixa a sessão iniciada) ou null. Não * redirecciona: quem chama decide o que fazer com o null. * * $opts — as duas coisas que um site pode ter de dizer, e que este ficheiro não * pode adivinhar. Sem elas o comportamento é o de sempre. * * 'allow' => fn(PDO $pdo, array $user): bool * Uma regra do site sobre quem pode entrar, a somar ao auth_active_sql() * (ex.: o dance recusa uma conta com o email por confirmar fora do prazo). * false trata-se como a conta ter deixado de existir: caem todos os * tokens dela, porque a mesma recusa valeria para qualquer outro. * * 'login' => fn(PDO $pdo, array $user): void * O que abre a sessão, em vez do auth_login() nu. Um site que guarda mais * na sessão do que o contrato partilhado (o dance põe lá a linha da conta * e os papéis) tem de ser ele a abri-la — senão a sessão reposta pelo * cookie seria diferente da que o login abre, e o portal via-a vazia. * Tem de chamar auth_login() (ou algo que o chame). */ function auth_remember_resume(PDO $pdo, array $opts = []): ?array { if (auth_check()) return auth_user(); // já há sessão; nada a repor $cookie = auth_remember_parse_cookie(); if ($cookie === null) return null; try { auth_remember_ensure_table($pdo); $st = $pdo->prepare( "SELECT * FROM `auth_remember_tokens` WHERE `selector` = ? LIMIT 1" ); $st->execute([$cookie['selector']]); $row = $st->fetch(PDO::FETCH_ASSOC) ?: null; } catch (Throwable $e) { error_log('[auth/remember] lookup failed: ' . $e->getMessage()); return null; } if ($row === null) { // Selector desconhecido — token já gasto, expirado e podado, ou lixo. auth_remember_forget_cookie(); return null; } if (!hash_equals((string) $row['validator_hash'], hash('sha256', $cookie['validator']))) { // Selector certo, validator errado. Ver a nota sobre rotação no topo: // isto é a assinatura de um cookie em circulação, não um engano. error_log('[auth/remember] validator mismatch for user ' . (int) $row['user_id'] . ' — a revogar todos os tokens da conta'); auth_remember_forget_user($pdo, (int) $row['user_id']); auth_remember_forget_cookie(); return null; } if (strtotime((string) $row['expires_at']) <= time()) { auth_remember_delete($pdo, (string) $row['selector']); auth_remember_forget_cookie(); return null; } // Token bom. Carregar a conta pelo mapa de colunas do site — e voltar a // passar por auth_active_sql(), porque uma conta desactivada depois de o // cookie ser emitido não pode entrar por esta porta. $s = auth_schema(); try { $u = $pdo->prepare( "SELECT * FROM `{$s['table']}` WHERE `{$s['id']}` = ? AND " . auth_active_sql() . " LIMIT 1" ); $u->execute([(int) $row['user_id']]); $user = $u->fetch(PDO::FETCH_ASSOC) ?: null; } catch (Throwable $e) { error_log('[auth/remember] user load failed: ' . $e->getMessage()); return null; } if ($user !== null && isset($opts['allow']) && is_callable($opts['allow'])) { try { if (!($opts['allow'])($pdo, $user)) { $user = null; } } catch (Throwable $e) { // Falha aberta para o ecrã de login, não para dentro do portal: uma // regra que não se conseguiu avaliar não deixa ninguém passar. error_log('[auth/remember] allow hook failed: ' . $e->getMessage()); return null; } } if ($user === null) { auth_remember_forget_user($pdo, (int) $row['user_id']); auth_remember_forget_cookie(); return null; } try { if (isset($opts['login']) && is_callable($opts['login'])) { ($opts['login'])($pdo, $user); } else { auth_login($pdo, $user); } } catch (Throwable $e) { // Um cookie nunca pode partir a página — nem o site público, que também // passa por aqui. Quem vier a seguir vê o ecrã de login. error_log('[auth/remember] login failed: ' . $e->getMessage()); return null; } $_SESSION['_auth_remembered'] = true; // Rotação: o validator que acabou de ser usado deixa de valer. auth_remember_rotate($pdo, (string) $row['selector']); return $user; } /** Gasta o validator actual e emite outro para o mesmo selector. */ function auth_remember_rotate(PDO $pdo, string $selector): void { $validator = bin2hex(random_bytes(32)); $expires = time() + auth_remember_days() * 86400; try { $pdo->prepare( "UPDATE `auth_remember_tokens` SET `validator_hash` = ?, `expires_at` = ?, `last_used_at` = NOW(), `last_ip` = ? WHERE `selector` = ?" )->execute([ hash('sha256', $validator), date('Y-m-d H:i:s', $expires), platform_client_ip(), $selector, ]); } catch (Throwable $e) { error_log('[auth/remember] rotate failed: ' . $e->getMessage()); return; } auth_remember_set_cookie($selector . ':' . $validator, $expires); } /** Apaga o cookie do browser, sem tocar na base de dados. */ function auth_remember_forget_cookie(): void { auth_remember_set_cookie('', time() - 42000); unset($_COOKIE[auth_remember_cookie_name()]); } function auth_remember_delete(PDO $pdo, string $selector): void { try { $pdo->prepare("DELETE FROM `auth_remember_tokens` WHERE `selector` = ?") ->execute([$selector]); } catch (Throwable $e) { error_log('[auth/remember] delete failed: ' . $e->getMessage()); } } /** * Esquece este dispositivo: apaga o token que o cookie actual identifica e o * próprio cookie. É o que o logout deve chamar — sair numa máquina não pode * terminar a sessão do telemóvel. */ function auth_remember_clear(PDO $pdo): void { $cookie = auth_remember_parse_cookie(); if ($cookie !== null) auth_remember_delete($pdo, $cookie['selector']); auth_remember_forget_cookie(); } /** * Esquece TODOS os dispositivos de uma conta. Para a mudança de palavra-passe, * para a suspeita de roubo, e para o botão "terminar sessão em todo o lado". */ function auth_remember_forget_user(PDO $pdo, int $userId): int { try { $st = $pdo->prepare("DELETE FROM `auth_remember_tokens` WHERE `user_id` = ?"); $st->execute([$userId]); return $st->rowCount(); } catch (Throwable $e) { error_log('[auth/remember] forget_user failed: ' . $e->getMessage()); return 0; } } /** Remove tokens expirados. Idempotente, barato, seguro de chamar sempre. */ function auth_remember_prune(PDO $pdo): void { try { $pdo->exec("DELETE FROM `auth_remember_tokens` WHERE `expires_at` <= NOW()"); } catch (Throwable $e) { error_log('[auth/remember] prune failed: ' . $e->getMessage()); } } /** * A sessão actual veio de um cookie em vez de uma palavra-passe escrita agora? * * Quem for pedir uma operação sensível deve exigir autenticação fresca quando * isto for verdade. */ function auth_remember_active(): bool { return !empty($_SESSION['_auth_remembered']); } /** Tokens activos de uma conta — para uma página "as minhas sessões". */ function auth_remember_list(PDO $pdo, int $userId): array { try { $st = $pdo->prepare( "SELECT `id`, `selector`, `created_at`, `last_used_at`, `last_ip`, `user_agent`, `expires_at` FROM `auth_remember_tokens` WHERE `user_id` = ? AND `expires_at` > NOW() ORDER BY `last_used_at` DESC, `created_at` DESC" ); $st->execute([$userId]); return $st->fetchAll(PDO::FETCH_ASSOC) ?: []; } catch (Throwable $e) { error_log('[auth/remember] list failed: ' . $e->getMessage()); return []; } }