- 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>
380 lines
15 KiB
PHP
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 [];
|
|
}
|
|
}
|