feat(auth): «Manter sessão iniciada» (auditoria: item Médio)

config/functions_remember.php reimplementa o desenho selector/validator
do platform-tools (functions_remember.php) localmente, porque esta app
tem autenticação própria e não passa por functions_auth.php partilhado:
selector em claro e UNIQUE indexa a linha, só o SHA-256 do validator
fica na BD, hash_equals() na comparação, rotação a cada uso, e um
validator que não bate certo revoga TODOS os tokens da conta (sinal de
cookie em circulação).

- Checkbox no login; remember_issue() só quando marcada.
- require_login() tenta remember_resume() antes de mandar para o login.
- logout.php apaga só o token deste browser (não a conta toda).
- auth_set_password() revoga todos os tokens da conta — uma password
  nova não pode deixar um cookie antigo continuar a entrar.

Migração: database/migration_20260925_remember_me.sql (remember_tokens).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Sonnet 5 committed 2026-09-25 13:01:18 +01:00
1 parent ee30e6f116
commit 300655b92a
7 files changed
+188

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
<?php
// ─── «Manter sessão iniciada» ────────────────────────────────────────────────
// Mesmo desenho do platform-tools functions_remember.php, reimplementado aqui
// porque esta app tem autenticação própria (config/auth.php, não
// functions_auth.php partilhado) e chama auth_set_session() directamente.
//
// Cookie: "<selector>:<validator>". 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 */ }
}