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:
1 parent
ee30e6f116
commit
300655b92a
7 files changed
+188
No files matched your search
@@ -6,6 +6,11 @@
|
|||||||
// of the app.
|
// of the app.
|
||||||
|
|
||||||
function require_login(): array {
|
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'])) {
|
if (empty($_SESSION['user_id'])) {
|
||||||
$return = $_SERVER['REQUEST_URI'] ?? '';
|
$return = $_SERVER['REQUEST_URI'] ?? '';
|
||||||
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
|
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
|
||||||
|
|||||||
@@ -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_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__ . '/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__ . '/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_throttle.php'; // per-IP attempt throttle for the auth endpoints
|
||||||
require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner
|
require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner
|
||||||
|
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
|
}
|
||||||
@@ -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 {
|
function auth_set_password(PDO $pdo, int $userId, string $password): void {
|
||||||
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
$pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||||
->execute([password_hash($password, PASSWORD_DEFAULT), $userId]);
|
->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;
|
$self = (int) ($_SESSION['user_id'] ?? 0) === $userId;
|
||||||
audit($self ? 'auth.password_change' : 'user.password_set', [
|
audit($self ? 'auth.password_change' : 'user.password_set', [
|
||||||
'target_type' => 'user', 'target_id' => $userId,
|
'target_type' => 'user', 'target_id' => $userId,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -57,6 +57,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$user = auth_login($pdo, $_POST['email'] ?? '', $_POST['password'] ?? '');
|
$user = auth_login($pdo, $_POST['email'] ?? '', $_POST['password'] ?? '');
|
||||||
if ($user) {
|
if ($user) {
|
||||||
auth_set_session($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']]);
|
audit('auth.login', ['target_type' => 'user', 'target_id' => (int) $user['id'], 'target_label' => (string) $user['name']]);
|
||||||
header('Location: ' . base_url($destination($user['role'])));
|
header('Location: ' . base_url($destination($user['role'])));
|
||||||
exit;
|
exit;
|
||||||
@@ -122,6 +125,10 @@ include __DIR__ . '/../../includes/auth_header.php';
|
|||||||
<a href="<?= htmlspecialchars(base_url('views/auth/forgot_password.php')) ?>" class="small">Esqueceu a <span class="text-nowrap">palavra-passe</span>?</a>
|
<a href="<?= htmlspecialchars(base_url('views/auth/forgot_password.php')) ?>" class="small">Esqueceu a <span class="text-nowrap">palavra-passe</span>?</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="login-remember" name="remember" value="1">
|
||||||
|
<label class="form-check-label small" for="login-remember">Manter sessão iniciada</label>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn-primary w-100">Entrar</button>
|
<button type="submit" class="btn btn-primary w-100">Entrar</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ require __DIR__ . '/../../config/functions.php';
|
|||||||
|
|
||||||
if (!empty($_SESSION['user_id'])) {
|
if (!empty($_SESSION['user_id'])) {
|
||||||
audit('auth.logout', ['target_type' => 'user', 'target_id' => (int) $_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 = [];
|
$_SESSION = [];
|
||||||
// Drop the cookie too, so the browser doesn't keep presenting a dead id.
|
// Drop the cookie too, so the browser doesn't keep presenting a dead id.
|
||||||
if (ini_get('session.use_cookies')) {
|
if (ini_get('session.use_cookies')) {
|
||||||
|
|||||||
Reference in new issue
Block a user