Files
fitme3070.pt/shared/platform-tools/functions_remember.php
T
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de
  audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de
  segurança da frota (o gancho que o login já tinha passa a funcionar) e a
  correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For
  / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele.
- config/functions_audit.php: audit() sobre platform_audit(); tabela
  audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação.
- Registado: login, falha de login, bloqueio por excesso de tentativas,
  logout, pedidos de recuperação, mudança/definição de palavra-passe,
  registo e criação de utilizadores, convites, perfil (antes/depois), todas
  as definições (db_update_setting, com valor anterior), cada passo da
  lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens.
- Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:28:13 +01:00

380 lines
15 KiB
PHP

<?php
// ─── "Manter sessão iniciada" — login persistente ────────────────────────────
//
// A sessão do PHP morre quando o browser fecha, e o garbage collector apaga-a
// do servidor bem antes disso — `session.gc_maxlifetime` são 24 minutos por
// omissão. Esticar o cookie da sessão para trinta dias não resolve: o cookie
// sobrevive, os dados do lado do servidor não, e o utilizador é expulso na
// mesma, agora sem perceber porquê. Um "lembrar-me" honesto precisa de estado
// próprio, guardado numa tabela, com validade própria.
//
// MECANISMO — selector/validator
//
// O cookie leva dois valores separados por dois pontos: `<selector>:<validator>`.
//
// 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 [];
}
}