HTTP é um protocolo sem estado (stateless). Cada requisição é independente — o servidor não sabe, por padrão, que a requisição do usuário A às 14h05 foi feita pela mesma pessoa que fez a requisição às 14h03. Isso é ótimo para escalabilidade, mas péssimo para qualquer aplicação que precise de contexto: login, carrinho de compras, preferências de idioma.
Sessions e cookies são os mecanismos que adicionam estado ao HTTP. Sessions guardam dados no servidor; cookies guardam dados no navegador. Entender a diferença, os casos de uso de cada um, e como usá-los com segurança é fundamental.
Cookies — dados no navegador
Um cookie é um par chave-valor que o servidor envia ao navegador, e o navegador devolve automaticamente em todas as requisições seguintes para o mesmo domínio:
<?php
// Criar um cookie
// setcookie() deve ser chamado ANTES de qualquer saída HTML
setcookie(
name: 'idioma',
value: 'pt-BR',
expires_or_options: time() + (86400 * 30), // 30 dias
path: '/',
domain: '',
secure: true, // Apenas HTTPS
httponly: true, // JavaScript não pode acessar
);
// Ler um cookie
$idioma = $_COOKIE['idioma'] ?? 'pt-BR';
// Deletar um cookie: defina expires no passado
setcookie('idioma', '', time() - 3600, '/');
Os parâmetros secure: true e httponly: true são fundamentais para segurança. httponly impede que JavaScript leia o cookie — crucial para cookies de sessão, pois bloqueia ataques XSS que tentam roubar cookies.
Sessions — dados no servidor
Sessions funcionam assim: o servidor cria um identificador único (session ID), armazena dados no servidor associados a esse ID, e envia o ID ao navegador como cookie. Em cada requisição, o navegador devolve o cookie com o ID, e o servidor recupera os dados:
<?php
// Configurações de segurança ANTES de session_start()
ini_set('session.cookie_httponly', '1');
// Apenas HTTPS. Em desenvolvimento local, sob http://, este '1' faz o
// navegador não devolver o cookie de sessão: o login "não funciona" e
// nenhum erro aparece, porque do lado do PHP a sessão é só uma nova a
// cada requisição. Ligue conforme o ambiente, e não fixo.
ini_set('session.cookie_secure', isset($_SERVER['HTTPS']) ? '1' : '0');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.use_strict_mode', '1'); // Rejeita session IDs não iniciados pelo servidor
session_start();
// Armazenar dados na sessão
$_SESSION['usuario_id'] = 42;
$_SESSION['usuario_nome'] = 'Alice';
$_SESSION['logado_em'] = time();
// Ler dados da sessão
if (isset($_SESSION['usuario_id'])) {
echo "Bem-vindo, " . htmlspecialchars($_SESSION['usuario_nome']) . "!";
}
// Remover um dado específico
unset($_SESSION['carrinho_temporario']);
// Destruir a sessão completamente (logout)
session_unset();
session_destroy();
// Também limpe o cookie
setcookie(session_name(), '', time() - 3600, '/');
Regeneração de session ID — prevenindo session fixation
Um ataque de session fixation acontece quando um atacante força a vítima a usar um session ID conhecido. Ao fazer login, sempre regenere o ID:
<?php
session_start();
function fazerLogin(string $email, string $senha, PDO $pdo): bool
{
$stmt = $pdo->prepare('SELECT id, nome, senha_hash FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
$usuario = $stmt->fetch();
if (!$usuario || !password_verify($senha, $usuario['senha_hash'])) {
return false;
}
// CRÍTICO: regenerar o ID antes de armazenar dados de autenticação
// Invalida o session ID antigo, cria um novo
session_regenerate_id(delete_old_session: true);
$_SESSION['usuario_id'] = $usuario['id'];
$_SESSION['usuario_nome'] = $usuario['nome'];
$_SESSION['logado_em'] = time();
return true;
}
Sistema de autenticação completo
<?php
declare(strict_types=1);
class Auth
{
private const SESSION_LIFETIME = 7200; // 2 horas
private const MAX_TENTATIVAS = 5;
public function __construct(private readonly PDO $pdo) {}
public function login(string $email, string $senha): bool
{
// Proteção contra brute-force
if ($this->excedeuTentativas($email)) {
throw new RuntimeException('Muitas tentativas. Tente novamente em 15 minutos.');
}
$stmt = $this->pdo->prepare(
'SELECT id, nome, senha_hash FROM usuarios WHERE email = ? AND ativo = 1'
);
$stmt->execute([$email]);
$usuario = $stmt->fetch();
if (!$usuario || !password_verify($senha, $usuario['senha_hash'])) {
$this->registrarTentativa($email);
return false;
}
$this->limparTentativas($email);
session_regenerate_id(delete_old_session: true);
$_SESSION['uid'] = $usuario['id'];
$_SESSION['nome'] = $usuario['nome'];
$_SESSION['inicio'] = time();
$_SESSION['ip'] = $_SERVER['REMOTE_ADDR'];
$_SESSION['ua'] = $_SERVER['HTTP_USER_AGENT'] ?? '';
return true;
}
public function verificar(): bool
{
if (!isset($_SESSION['uid'])) return false;
// Timeout por inatividade
if (time() - $_SESSION['inicio'] > self::SESSION_LIFETIME) {
$this->logout();
return false;
}
// O user agent estava sendo guardado no login e nunca conferido.
// Ele muda quando o navegador se atualiza, o que derruba a sessão de
// vez em quando — incômodo, porém raro e barato.
if ($_SESSION['ua'] !== ($_SERVER['HTTP_USER_AGENT'] ?? '')) {
$this->logout();
return false;
}
// Já a comparação de IP, que parece a defesa mais óbvia, é a que
// mais atrapalha na prática: em rede móvel o endereço troca ao mudar
// de antena, e atrás de proxy com várias saídas ele varia entre
// requisições. O usuário legítimo cai no meio do uso, enquanto quem
// roubou o cookie de dentro da mesma rede passa sem obstáculo.
// Mantenha se o seu público for de rede fixa; saiba que a defesa que
// de fato conta é o session_regenerate_id() na hora do login.
if ($_SESSION['ip'] !== ($_SERVER['REMOTE_ADDR'] ?? '')) {
$this->logout();
return false;
}
// Atualiza o timestamp de última atividade
$_SESSION['inicio'] = time();
return true;
}
public function logout(): void
{
session_unset();
session_destroy();
setcookie(session_name(), '', time() - 3600, '/', secure: true, httponly: true);
}
public function usuarioId(): ?int
{
return $_SESSION['uid'] ?? null;
}
private function excedeuTentativas(string $email): bool
{
// datetime('now', '-15 minutes') é sintaxe do SQLite e quebra no MySQL
// com "error 1064". Como o PDO não uniformiza função de data, a
// saída é tirar o cálculo do SQL: resolva em PHP e passe como
// parâmetro. A mesma query passa a rodar nos dois bancos.
$limite = (new DateTimeImmutable('-15 minutes'))->format('Y-m-d H:i:s');
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM tentativas_login
WHERE email = ? AND criado_em > ?'
);
$stmt->execute([$email, $limite]);
return (int) $stmt->fetchColumn() >= self::MAX_TENTATIVAS;
}
private function registrarTentativa(string $email): void
{
$this->pdo->prepare('INSERT INTO tentativas_login (email, ip) VALUES (?, ?)')
->execute([$email, $_SERVER['REMOTE_ADDR'] ?? '']);
}
private function limparTentativas(string $email): void
{
$this->pdo->prepare('DELETE FROM tentativas_login WHERE email = ?')
->execute([$email]);
}
}
Flash messages
Flash messages são mensagens de sessão que existem apenas até a próxima requisição — exibem feedback de operações após um redirect:
<?php
class Flash
{
public static function adicionar(string $tipo, string $mensagem): void
{
$_SESSION['flash'][] = ['tipo' => $tipo, 'mensagem' => $mensagem];
}
public static function obterETodos(): array
{
$mensagens = $_SESSION['flash'] ?? [];
unset($_SESSION['flash']);
return $mensagens;
}
}
// Uso: após salvar com sucesso
Flash::adicionar('sucesso', 'Produto cadastrado com sucesso!');
header('Location: /produtos');
exit;
// Na página de destino (produtos.php):
foreach (Flash::obterETodos() as $flash) {
echo "<div class='alerta {$flash['tipo']}'>{$flash['mensagem']}</div>";
}
Cookies de longo prazo — "Lembrar de mim"
O "Lembrar de mim" não deve estender a sessão PHP (que fica na memória do servidor). Deve usar um cookie separado com um token seguro persistido no banco:
<?php
function criarTokenLembrarMe(int $usuarioId, PDO $pdo): void
{
$token = bin2hex(random_bytes(32)); // 64 chars hex, criptograficamente seguro
$hash = hash('sha256', $token); // Armazena o hash, não o token
$pdo->prepare(
'INSERT INTO tokens_lembrar_me (usuario_id, token_hash, expira_em)
VALUES (?, ?, datetime("now", "+30 days"))'
)->execute([$usuarioId, $hash]);
setcookie('lembrar_me', $token, [
'expires' => time() + 86400 * 30,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
}
function verificarTokenLembrarMe(PDO $pdo): ?int
{
$token = $_COOKIE['lembrar_me'] ?? null;
if (!$token) return null;
$hash = hash('sha256', $token);
$stmt = $pdo->prepare(
"SELECT usuario_id FROM tokens_lembrar_me
WHERE token_hash = ? AND expira_em > datetime('now')"
);
$stmt->execute([$hash]);
$row = $stmt->fetch();
if (!$row) return null;
// Rotação de token: invalida o usado e cria um novo
$pdo->prepare('DELETE FROM tokens_lembrar_me WHERE token_hash = ?')->execute([$hash]);
criarTokenLembrarMe($row['usuario_id'], $pdo);
return (int) $row['usuario_id'];
}
A divisão de trabalho entre os dois é simples de enunciar e fácil de esquecer na hora de decidir: o cookie guarda o que pode ser público e o que o usuário pode trocar sem consequência — idioma, tema, aquele aviso que ele já fechou. A sessão guarda o resto, porque vive no servidor e o navegador só carrega o bilhete de identificação. Quem inverte isso acaba com um cookie usuario_id=42 que qualquer pessoa edita no DevTools.
De todas as defesas deste artigo, a que mais rende é uma linha: regenerar o identificador no momento do login. As outras têm custo escondido — amarrar a sessão ao IP derruba quem usa rede móvel, e o cookie_secure fixo em 1 faz o login "não funcionar" no ambiente local sem produzir um único erro. Vale escolher cada uma sabendo o que ela cobra, em vez de ligar todas por precaução.
Fontes e leituras recomendadas
- PHP Manual — Sessions: https://www.php.net/manual/pt_BR/book.session.php
- PHP Manual — setcookie: https://www.php.net/manual/pt_BR/function.setcookie.php
- OWASP — Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- Artigo sobre tokens "Lembrar de mim" seguro: https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence
- PHP Manual — opcoes de sessao no
php.ini, comcookie_samesiteeuse_strict_mode: https://www.php.net/manual/pt_BR/session.configuration.php - MDN — o atributo SameSite do cookie e o que muda com Lax, Strict e None: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie
Exercícios
Exercício 1
Implemente um sistema de login completo: formulário, verificação de senha com password_hash/password_verify, regeneração de session ID, middleware de proteção para páginas autenticadas.
Ver resposta
✓ Resposta: session_regenerate_id(true) no login é a linha que fecha o session fixation — e é a que mais se esquece. O fingerprint deliberadamente não inclui o IP: em rede móvel o endereço muda sozinho, e você derrubaria o usuário legítimo várias vezes por dia.
<?php
declare(strict_types=1);
final class Sessao
{
public static function iniciar(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
// Os parâmetros do cookie precisam ser definidos ANTES do
// session_start() — depois, não têm efeito.
session_set_cookie_params([
'lifetime' => 0, // some ao fechar o navegador
'path' => '/',
'domain' => '',
// Só trafega por HTTPS.
'secure' => ($_SERVER['HTTPS'] ?? '') === 'on',
// Invisível para JavaScript: barra roubo por XSS.
'httponly' => true,
// Não vai junto em requisição vinda de outro site: é a
// proteção nativa contra CSRF.
'samesite' => 'Lax',
]);
session_name('APPSESSID'); // esconde que é PHP
session_start();
self::validarFingerprint();
self::expirarPorInatividade(1800); // 30 min
}
/** Amarra a sessão ao navegador de origem. */
private static function validarFingerprint(): void
{
$atual = hash('sha256', ($_SERVER['HTTP_USER_AGENT'] ?? '')
. ($_ENV['APP_KEY'] ?? ''));
if (!isset($_SESSION['_fingerprint'])) {
$_SESSION['_fingerprint'] = $atual;
return;
}
// Não inclua o IP: rede móvel troca de IP e derrubaria o usuário
// legítimo a toda hora.
if (!hash_equals($_SESSION['_fingerprint'], $atual)) {
self::destruir();
session_start();
}
}
private static function expirarPorInatividade(int $segundos): void
{
if (isset($_SESSION['_ultimo_acesso'])
&& time() - $_SESSION['_ultimo_acesso'] > $segundos) {
self::destruir();
session_start();
}
$_SESSION['_ultimo_acesso'] = time();
}
public static function destruir(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 42000, 'path' => $p['path'],
'domain' => $p['domain'], 'secure' => $p['secure'],
'httponly' => $p['httponly'], 'samesite' => $p['samesite'] ?? 'Lax',
]);
}
session_destroy();
}
}
final class Autenticacao
{
public function __construct(private readonly PDO $pdo) {}
public function entrar(string $email, string $senha): bool
{
$stmt = $this->pdo->prepare(
'SELECT id, nome, senha_hash, papel FROM usuarios WHERE email = :e LIMIT 1'
);
$stmt->execute([':e' => mb_strtolower(trim($email))]);
$usuario = $stmt->fetch(PDO::FETCH_ASSOC);
// Hash descartável quando o usuário não existe: mantém o tempo de
// resposta constante e impede enumerar quem tem conta.
$hash = $usuario['senha_hash'] ?? '$2y$12$naoExisteUsuarioComEsteHashQualquer......';
if (!password_verify($senha, $hash) || $usuario === false) {
return false;
}
/**
* ⚠️ O PASSO MAIS IMPORTANTE: regenerar o ID no login.
*
* Sem isto, existe "session fixation": o atacante induz a vítima a
* usar um ID que ele já conhece (por link, por exemplo) e, quando ela
* faz login, aquele mesmo ID passa a estar autenticado — nas mãos dele.
* Regenerar cria um ID novo que o atacante não conhece.
*/
session_regenerate_id(true); // true = apaga a sessão antiga
$_SESSION['usuario_id'] = (int) $usuario['id'];
$_SESSION['usuario_nome']= $usuario['nome'];
$_SESSION['papel'] = $usuario['papel'];
$_SESSION['logado_em'] = time();
// Rehash transparente quando o custo do bcrypt sobe.
if (password_needs_rehash($usuario['senha_hash'], PASSWORD_DEFAULT)) {
$this->pdo->prepare('UPDATE usuarios SET senha_hash = :h WHERE id = :id')
->execute([':h' => password_hash($senha, PASSWORD_DEFAULT),
':id' => $usuario['id']]);
}
return true;
}
public function sair(): void
{
Sessao::destruir();
}
public static function autenticado(): bool
{
return isset($_SESSION['usuario_id']);
}
public static function usuarioId(): ?int
{
return $_SESSION['usuario_id'] ?? null;
}
}
/** Middleware de proteção. */
function exigirLogin(?string $papel = null): void
{
Sessao::iniciar();
if (!Autenticacao::autenticado()) {
// Guarda o destino para voltar depois do login.
$_SESSION['_destino'] = $_SERVER['REQUEST_URI'] ?? '/';
Flash::erro('Faça login para continuar.');
header('Location: /login.php', true, 303);
exit;
}
if ($papel !== null && ($_SESSION['papel'] ?? '') !== $papel) {
http_response_code(403);
exit('Acesso negado.');
}
}
// No topo de cada página protegida:
// exigirLogin(); — qualquer usuário autenticado
// exigirLogin('admin'); — só admin
Exercício 2
Adicione flash messages ao sistema: após login com sucesso, redirecione com mensagem "Bem-vindo, {nome}!". Após logout, redirecione com "Você saiu com sucesso."
Ver resposta
✓ Resposta: Duas armadilhas fechadas aqui: o redirecionamento pós-login valida o destino (senão vira open redirect, ótimo para phishing) e o logout exige POST — por GET, uma <img src="/logout.php"> em qualquer site desconectaria seu usuário.
<?php
declare(strict_types=1);
enum TipoFlash: string
{
case Sucesso = 'sucesso';
case Erro = 'erro';
case Aviso = 'aviso';
case Info = 'info';
public function icone(): string
{
return match ($this) {
self::Sucesso => '✅',
self::Erro => '❌',
self::Aviso => '⚠️',
self::Info => 'ℹ️',
};
}
public function classeCss(): string
{
return 'flash flash--' . $this->value;
}
}
final class Flash
{
private const CHAVE = '_flash';
public static function adicionar(TipoFlash $tipo, string $mensagem): void
{
$_SESSION[self::CHAVE][] = ['tipo' => $tipo->value, 'mensagem' => $mensagem];
}
public static function sucesso(string $m): void { self::adicionar(TipoFlash::Sucesso, $m); }
public static function erro(string $m): void { self::adicionar(TipoFlash::Erro, $m); }
public static function aviso(string $m): void { self::adicionar(TipoFlash::Aviso, $m); }
/** @return array<int, array{tipo: TipoFlash, mensagem: string}> */
public static function consumir(): array
{
$mensagens = $_SESSION[self::CHAVE] ?? [];
// A remoção é o que faz a mensagem aparecer uma única vez.
unset($_SESSION[self::CHAVE]);
return array_map(
static fn(array $f): array => [
'tipo' => TipoFlash::from($f['tipo']),
'mensagem' => $f['mensagem'],
],
$mensagens,
);
}
public static function temMensagens(): bool
{
return !empty($_SESSION[self::CHAVE]);
}
/** Renderiza e limpa. O escape é obrigatório: a mensagem pode conter nome. */
public static function renderizar(): string
{
$html = '';
foreach (self::consumir() as $flash) {
$html .= sprintf(
'<div class="%s" role="alert">%s %s</div>',
$flash['tipo']->classeCss(),
$flash['tipo']->icone(),
htmlspecialchars($flash['mensagem'], ENT_QUOTES, 'UTF-8'),
);
}
return $html;
}
}
// ---------------------------------------- public/login.php
declare(strict_types=1);
Sessao::iniciar();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$auth = new Autenticacao($pdo);
if ($auth->entrar($_POST['email'] ?? '', $_POST['senha'] ?? '')) {
Flash::sucesso("Bem-vindo, {$_SESSION['usuario_nome']}!");
// Volta para onde o usuário queria ir antes de ser barrado.
$destino = $_SESSION['_destino'] ?? '/painel.php';
unset($_SESSION['_destino']);
// ⚠️ Nunca redirecione para URL vinda do usuário sem validar: um
// destino externo transforma seu login em trampolim de phishing.
if (!str_starts_with($destino, '/') || str_starts_with($destino, '//')) {
$destino = '/painel.php';
}
header('Location: ' . $destino, true, 303);
exit;
}
Flash::erro('E-mail ou senha incorretos.');
header('Location: /login.php', true, 303);
exit;
}
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Entrar</title></head>
<body>
<?= Flash::renderizar() ?>
<form method="post">
<label>E-mail <input type="email" name="email" required autocomplete="email"></label>
<label>Senha <input type="password" name="senha" required autocomplete="current-password"></label>
<button type="submit">Entrar</button>
</form>
</body>
</html>
<?php
// ---------------------------------------- public/logout.php
declare(strict_types=1);
Sessao::iniciar();
// Logout por GET permite que uma <img src="/logout.php"> num site
// qualquer desconecte seu usuário. Exija POST.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Use POST para sair.');
}
(new Autenticacao($pdo))->sair();
// A sessão foi destruída: é preciso uma nova só para carregar o flash.
session_start();
session_regenerate_id(true);
Flash::sucesso('Você saiu com sucesso.');
header('Location: /login.php', true, 303);
exit;
Exercício 3
Implemente "Lembrar de mim" com rotação de token. Teste que o token antigo é invalidado após cada uso.
Ver resposta
✓ Resposta: O par seletor/validador não é preciosismo: o seletor permite buscar por índice, e o validador é comparado com hash_equals sobre o hash. E a detecção de reuso — validador errado para um seletor válido — é o que transforma um cookie roubado num incidente detectável, em vez de um acesso silencioso por 30 dias.
<?php
declare(strict_types=1);
// CREATE TABLE lembrar_tokens (
// id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
// usuario_id INT UNSIGNED NOT NULL,
// seletor CHAR(24) NOT NULL UNIQUE,
// validador_hash CHAR(64) NOT NULL,
// expira_em DATETIME NOT NULL,
// criado_em TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
// user_agent VARCHAR(255) NULL,
// INDEX idx_lembrar_usuario (usuario_id),
// CONSTRAINT fk_lembrar_usuario FOREIGN KEY (usuario_id)
// REFERENCES usuarios(id) ON DELETE CASCADE
// ) ENGINE=InnoDB;
declare(strict_types=1);
final class LembrarDeMim
{
private const COOKIE = 'lembrar';
private const DIAS = 30;
public function __construct(private readonly PDO $pdo) {}
/**
* O par seletor/validador existe por um motivo: o seletor é a chave de
* BUSCA (indexada, comparação normal) e o validador é o SEGREDO
* (comparado com hash_equals, guardado só como hash). Com um token
* único, a busca teria de ser pelo próprio segredo — o que obriga a
* guardá-lo em claro ou a varrer a tabela inteira comparando hashes.
*/
public function criar(int $usuarioId): void
{
$seletor = bin2hex(random_bytes(12)); // 24 chars
$validador = bin2hex(random_bytes(32));
$expira = new DateTimeImmutable('+' . self::DIAS . ' days');
$stmt = $this->pdo->prepare(
'INSERT INTO lembrar_tokens (usuario_id, seletor, validador_hash,
expira_em, user_agent)
VALUES (:u, :s, :v, :e, :ua)'
);
$stmt->execute([
':u' => $usuarioId,
':s' => $seletor,
':v' => hash('sha256', $validador),
':e' => $expira->format('Y-m-d H:i:s'),
':ua' => mb_substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
]);
setcookie(self::COOKIE, $seletor . ':' . $validador, [
'expires' => $expira->getTimestamp(),
'path' => '/',
'secure' => ($_SERVER['HTTPS'] ?? '') === 'on',
'httponly' => true,
'samesite' => 'Lax',
]);
}
/** @return int|null id do usuário, ou null se o cookie não vale */
public function tentarLogin(): ?int
{
$cookie = $_COOKIE[self::COOKIE] ?? '';
if (!str_contains($cookie, ':')) {
return null;
}
[$seletor, $validador] = explode(':', $cookie, 2);
$stmt = $this->pdo->prepare(
'SELECT id, usuario_id, validador_hash FROM lembrar_tokens
WHERE seletor = :s AND expira_em > NOW() LIMIT 1'
);
$stmt->execute([':s' => $seletor]);
$registro = $stmt->fetch(PDO::FETCH_ASSOC);
if ($registro === false) {
$this->limparCookie();
return null;
}
if (!hash_equals($registro['validador_hash'], hash('sha256', $validador))) {
/**
* Seletor válido + validador errado = alguém está usando um
* cookie roubado ou antigo. A resposta correta é derrubar TODAS
* as sessões "lembrar" do usuário: se o token foi copiado, o
* ladrão perde o acesso junto.
*/
$this->revogarTodos((int) $registro['usuario_id']);
$this->limparCookie();
error_log("Reuso de token detectado para o usuário {$registro['usuario_id']}");
return null;
}
$usuarioId = (int) $registro['usuario_id'];
// ROTAÇÃO: o token usado morre e nasce outro. Um cookie interceptado
// só serve até o próximo acesso legítimo do dono.
$this->rotacionar((int) $registro['id'], $usuarioId);
return $usuarioId;
}
private function rotacionar(int $tokenId, int $usuarioId): void
{
$this->pdo->beginTransaction();
try {
$this->pdo->prepare('DELETE FROM lembrar_tokens WHERE id = :id')
->execute([':id' => $tokenId]);
$this->criar($usuarioId);
$this->pdo->commit();
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function revogar(): void
{
$cookie = $_COOKIE[self::COOKIE] ?? '';
if (str_contains($cookie, ':')) {
[$seletor] = explode(':', $cookie, 2);
$this->pdo->prepare('DELETE FROM lembrar_tokens WHERE seletor = :s')
->execute([':s' => $seletor]);
}
$this->limparCookie();
}
public function revogarTodos(int $usuarioId): int
{
$stmt = $this->pdo->prepare('DELETE FROM lembrar_tokens WHERE usuario_id = :u');
$stmt->execute([':u' => $usuarioId]);
return $stmt->rowCount();
}
public function limparExpirados(): int
{
$stmt = $this->pdo->query('DELETE FROM lembrar_tokens WHERE expira_em < NOW()');
return $stmt->rowCount();
}
private function limparCookie(): void
{
setcookie(self::COOKIE, '', ['expires' => time() - 3600, 'path' => '/']);
}
}
// ---------------------------------------- Teste da rotação
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class LembrarDeMimTest extends TestCase
{
#[Test]
public function tokenAntigoDeixaDeValerAposOUso(): void
{
$lembrar = new LembrarDeMim($this->pdo);
$lembrar->criar(usuarioId: 7);
$primeiro = $_COOKIE['lembrar'] = $this->cookieGerado();
// 1º uso: funciona e rotaciona.
self::assertSame(7, $lembrar->tentarLogin());
$segundo = $this->cookieGerado();
self::assertNotSame($primeiro, $segundo, 'O token precisa mudar a cada uso.');
// Reapresentar o token ANTIGO: o seletor nem existe mais.
$_COOKIE['lembrar'] = $primeiro;
self::assertNull($lembrar->tentarLogin());
}
#[Test]
public function validadorErradoRevogaTodosOsTokensDoUsuario(): void
{
$lembrar = new LembrarDeMim($this->pdo);
$lembrar->criar(usuarioId: 7);
[$seletor] = explode(':', $this->cookieGerado(), 2);
$_COOKIE['lembrar'] = $seletor . ':validador-falso';
self::assertNull($lembrar->tentarLogin());
self::assertSame(0, $this->contarTokensDe(7), 'Todos deveriam ter sido revogados.');
}
}
Exercício 4
Crie um painel /admin/sessoes que liste as sessões ativas do banco (requer salvar metadados de sessão em tabela própria). Implemente um botão "Encerrar esta sessão" que invalida o token.
Ver resposta
✓ Resposta: O detalhe que mais importa: o session id nunca vai para a tela. Quem lê o id de uma sessão pode assumi-la. Por isso o painel expõe só um derivado curto do hash, suficiente para identificar a linha no botão de encerrar e inútil para sequestrar.
<?php
declare(strict_types=1);
// CREATE TABLE sessoes (
// id VARCHAR(128) PRIMARY KEY, -- o próprio session_id
// usuario_id INT UNSIGNED NULL,
// ip_hash CHAR(64) NULL,
// user_agent VARCHAR(255) NULL,
// ultima_atividade DATETIME NOT NULL,
// criada_em TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
// INDEX idx_sessoes_usuario (usuario_id, ultima_atividade)
// ) ENGINE=InnoDB;
declare(strict_types=1);
final class RegistroDeSessoes
{
public function __construct(private readonly PDO $pdo) {}
/** Chamado a cada requisição autenticada. */
public function registrar(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
return;
}
$stmt = $this->pdo->prepare(
'INSERT INTO sessoes (id, usuario_id, ip_hash, user_agent, ultima_atividade)
VALUES (:id, :u, :ip, :ua, NOW())
ON DUPLICATE KEY UPDATE
usuario_id = VALUES(usuario_id), ultima_atividade = NOW()'
);
$stmt->execute([
':id' => session_id(),
':u' => $_SESSION['usuario_id'] ?? null,
':ip' => hash_hmac('sha256', $_SERVER['REMOTE_ADDR'] ?? '',
$_ENV['APP_KEY'] ?? 'chave'),
':ua' => mb_substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
]);
}
/** @return array<int, array> sessões do usuário, atual em primeiro */
public function doUsuario(int $usuarioId): array
{
$stmt = $this->pdo->prepare(
'SELECT id, user_agent, ultima_atividade, criada_em
FROM sessoes
WHERE usuario_id = :u
AND ultima_atividade > DATE_SUB(NOW(), INTERVAL 30 MINUTE)
ORDER BY ultima_atividade DESC'
);
$stmt->execute([':u' => $usuarioId]);
$atual = session_id();
return array_map(static function (array $s) use ($atual): array {
$s['atual'] = hash_equals($atual, $s['id']);
$s['dispositivo'] = self::descreverDispositivo($s['user_agent'] ?? '');
// O id da sessão NUNCA vai para a tela: quem o vê, sequestra.
// Só um identificador curto, suficiente para o botão de encerrar.
$s['referencia'] = substr(hash('sha256', $s['id']), 0, 12);
unset($s['id']);
return $s;
}, $stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function encerrar(int $usuarioId, string $referencia): bool
{
$stmt = $this->pdo->prepare(
'SELECT id FROM sessoes WHERE usuario_id = :u'
);
$stmt->execute([':u' => $usuarioId]);
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $sessionId) {
if (!hash_equals(substr(hash('sha256', $sessionId), 0, 12), $referencia)) {
continue;
}
// Apagar o registro não basta: o arquivo/linha da sessão em si
// precisa sumir, senão ela continua válida.
$this->destruirSessao($sessionId);
$this->pdo->prepare('DELETE FROM sessoes WHERE id = :id')
->execute([':id' => $sessionId]);
return true;
}
return false;
}
public function encerrarOutras(int $usuarioId): int
{
$atual = session_id();
$stmt = $this->pdo->prepare(
'SELECT id FROM sessoes WHERE usuario_id = :u AND id <> :atual'
);
$stmt->execute([':u' => $usuarioId, ':atual' => $atual]);
$encerradas = 0;
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $sessionId) {
$this->destruirSessao($sessionId);
$encerradas++;
}
$this->pdo->prepare('DELETE FROM sessoes WHERE usuario_id = :u AND id <> :atual')
->execute([':u' => $usuarioId, ':atual' => $atual]);
return $encerradas;
}
private function destruirSessao(string $sessionId): void
{
// Com o handler em banco (exercício 5), basta apagar a linha.
$this->pdo->prepare('DELETE FROM sessoes_dados WHERE id = :id')
->execute([':id' => $sessionId]);
}
private static function descreverDispositivo(string $ua): string
{
$navegador = match (true) {
str_contains($ua, 'Firefox/') => 'Firefox',
str_contains($ua, 'Edg/') => 'Edge',
str_contains($ua, 'Chrome/') => 'Chrome',
str_contains($ua, 'Safari/') => 'Safari',
default => 'Navegador desconhecido',
};
$sistema = match (true) {
str_contains($ua, 'Windows') => 'Windows',
str_contains($ua, 'Android') => 'Android',
str_contains($ua, 'iPhone'), str_contains($ua, 'iPad') => 'iOS',
str_contains($ua, 'Mac OS') => 'macOS',
str_contains($ua, 'Linux') => 'Linux',
default => 'Sistema desconhecido',
};
return "{$navegador} · {$sistema}";
}
}
// ---------------------------------------- public/admin/sessoes.php
declare(strict_types=1);
exigirLogin();
$registro = new RegistroDeSessoes($pdo);
$usuarioId = Autenticacao::usuarioId();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(419);
exit('Token inválido.');
}
if (($_POST['acao'] ?? '') === 'encerrar_outras') {
$n = $registro->encerrarOutras($usuarioId);
Flash::sucesso("{$n} sessão(ões) encerrada(s).");
} elseif (!empty($_POST['referencia'])) {
$registro->encerrar($usuarioId, $_POST['referencia'])
? Flash::sucesso('Sessão encerrada.')
: Flash::erro('Sessão não encontrada.');
}
header('Location: /admin/sessoes.php', true, 303);
exit;
}
$sessoes = $registro->doUsuario($usuarioId);
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Sessões ativas</title></head>
<body>
<?= Flash::renderizar() ?>
<h1>Sessões ativas</h1>
<table>
<thead><tr><th>Dispositivo</th><th>Última atividade</th><th></th></tr></thead>
<tbody>
<?php foreach ($sessoes as $s): ?>
<tr>
<td>
<?= htmlspecialchars($s['dispositivo'], ENT_QUOTES) ?>
<?php if ($s['atual']): ?><strong>(esta)</strong><?php endif; ?>
</td>
<td><?= htmlspecialchars($s['ultima_atividade'], ENT_QUOTES) ?></td>
<td>
<?php if (!$s['atual']): ?>
<form method="post">
<input type="hidden" name="csrf"
value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES) ?>">
<input type="hidden" name="referencia"
value="<?= htmlspecialchars($s['referencia'], ENT_QUOTES) ?>">
<button type="submit">Encerrar</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<form method="post">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES) ?>">
<input type="hidden" name="acao" value="encerrar_outras">
<button type="submit">Encerrar todas as outras sessões</button>
</form>
</body>
</html>
Exercício 5
Desafio: implemente um handler de sessão customizado (SessionHandlerInterface) que armazena sessões no banco de dados em vez de arquivos — necessário para aplicações rodando em múltiplos servidores.
Ver resposta
✓ Resposta: Três configurações que acompanham o handler: use_strict_mode (recusa id inexistente — outra defesa contra fixation), use_only_cookies (nunca aceitar session id pela URL) e lazy_write, que evita reescrever o BLOB quando nada mudou. Sem o último, todo pageview vira uma escrita no banco.
<?php
declare(strict_types=1);
// CREATE TABLE sessoes_dados (
// id VARCHAR(128) PRIMARY KEY,
// dados MEDIUMBLOB NOT NULL,
// usuario_id INT UNSIGNED NULL,
// expira_em INT UNSIGNED NOT NULL,
// INDEX idx_sessoes_expira (expira_em),
// INDEX idx_sessoes_usuario (usuario_id)
// ) ENGINE=InnoDB;
declare(strict_types=1);
/**
* O handler padrão grava em arquivo no disco local — o que quebra assim que
* há mais de um servidor: cada requisição pode cair numa máquina diferente,
* e a sessão "some". Guardando no banco, todos os servidores enxergam a
* mesma sessão.
*
* SessionUpdateTimestampHandlerInterface evita reescrever o BLOB a cada
* requisição quando nada mudou (lazy_write), e SessionIdInterface permite
* gerar ids mais robustos.
*/
final class HandlerSessaoBanco implements
SessionHandlerInterface,
SessionUpdateTimestampHandlerInterface,
SessionIdInterface
{
private ?string $hashInicial = null;
public function __construct(
private readonly PDO $pdo,
private readonly int $tempoVida = 1800,
) {}
public function open(string $path, string $name): bool
{
return true; // a conexão já vem pronta
}
public function close(): bool
{
return true;
}
public function read(string $id): string
{
$stmt = $this->pdo->prepare(
'SELECT dados FROM sessoes_dados WHERE id = :id AND expira_em > :agora'
);
$stmt->execute([':id' => $id, ':agora' => time()]);
$dados = $stmt->fetchColumn();
// read() DEVE devolver string — null quebra o PHP.
$dados = $dados === false ? '' : (string) $dados;
// Guarda o estado inicial para o lazy_write comparar depois.
$this->hashInicial = md5($dados);
return $dados;
}
public function write(string $id, string $data): bool
{
// ON DUPLICATE KEY: cria ou atualiza sem precisar consultar antes.
$stmt = $this->pdo->prepare(
'INSERT INTO sessoes_dados (id, dados, usuario_id, expira_em)
VALUES (:id, :dados, :usuario, :expira)
ON DUPLICATE KEY UPDATE
dados = VALUES(dados),
usuario_id = VALUES(usuario_id),
expira_em = VALUES(expira_em)'
);
return $stmt->execute([
':id' => $id,
':dados' => $data,
// Desserializar aqui permite indexar o usuário e listar as
// sessões dele no painel do exercício 4.
':usuario' => $this->extrairUsuarioId($data),
':expira' => time() + $this->tempoVida,
]);
}
public function destroy(string $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM sessoes_dados WHERE id = :id');
return $stmt->execute([':id' => $id]);
}
/** Coletor de lixo: o PHP chama de vez em quando, por probabilidade. */
public function gc(int $max_lifetime): int|false
{
$stmt = $this->pdo->prepare('DELETE FROM sessoes_dados WHERE expira_em < :agora');
$stmt->execute([':agora' => time()]);
return $stmt->rowCount();
}
/**
* Chamado quando a sessão NÃO mudou (com session.lazy_write = 1).
* Só empurra a expiração — evita reescrever um BLOB inteiro a cada
* requisição, o que em site movimentado é muita escrita à toa.
*/
public function updateTimestamp(string $id, string $data): bool
{
$stmt = $this->pdo->prepare(
'UPDATE sessoes_dados SET expira_em = :expira WHERE id = :id'
);
return $stmt->execute([':expira' => time() + $this->tempoVida, ':id' => $id]);
}
/** Confirma se o id já existe — parte da proteção contra fixation. */
public function validateId(string $id): bool
{
$stmt = $this->pdo->prepare(
'SELECT 1 FROM sessoes_dados WHERE id = :id AND expira_em > :agora'
);
$stmt->execute([':id' => $id, ':agora' => time()]);
return $stmt->fetchColumn() !== false;
}
public function create_sid(): string
{
// 32 bytes de entropia, bem acima do padrão do PHP.
return bin2hex(random_bytes(32));
}
private function extrairUsuarioId(string $data): ?int
{
// O formato de sessão do PHP é "chave|valor_serializado".
if (!preg_match('/usuario_id\|i:(\d+);/', $data, $m)) {
return null;
}
return (int) $m[1];
}
}
// ---------------------------------------- bootstrap
declare(strict_types=1);
$handler = new HandlerSessaoBanco($pdo, tempoVida: 1800);
// O registro tem de vir ANTES do session_start().
session_set_save_handler($handler, true);
ini_set('session.gc_maxlifetime', '1800');
ini_set('session.lazy_write', '1'); // usa o updateTimestamp
ini_set('session.use_strict_mode', '1'); // recusa id que não existe
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.use_only_cookies', '1'); // nunca aceita id pela URL
session_set_cookie_params([
'lifetime' => 0, 'path' => '/',
'secure' => ($_SERVER['HTTPS'] ?? '') === 'on',
'httponly' => true, 'samesite' => 'Lax',
]);
session_start();
$_SESSION['usuario_id'] = 7;
$_SESSION['carrinho'] = ['produto_1' => 2];
// Agora qualquer servidor do balanceador enxerga esta sessão.
// ---------------------------------------- Teste
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class HandlerSessaoBancoTest extends TestCase
{
#[Test]
public function escreveELeOsMesmosDados(): void
{
$handler = new HandlerSessaoBanco($this->pdo);
$handler->write('sid-teste', 'usuario_id|i:7;');
self::assertSame('usuario_id|i:7;', $handler->read('sid-teste'));
}
#[Test]
public function sessaoExpiradaLeComoVazia(): void
{
$handler = new HandlerSessaoBanco($this->pdo, tempoVida: -10);
$handler->write('sid-velha', 'usuario_id|i:7;');
// Já nasceu expirada: read devolve string vazia, não os dados.
self::assertSame('', $handler->read('sid-velha'));
}
#[Test]
public function gcRemoveApenasAsExpiradas(): void
{
(new HandlerSessaoBanco($this->pdo, tempoVida: -10))->write('velha', 'x|i:1;');
(new HandlerSessaoBanco($this->pdo, tempoVida: 3600))->write('nova', 'x|i:2;');
$removidas = (new HandlerSessaoBanco($this->pdo))->gc(1800);
self::assertSame(1, $removidas);
self::assertSame('x|i:2;', (new HandlerSessaoBanco($this->pdo))->read('nova'));
}
#[Test]
public function usuarioIdEhExtraidoParaAColuna(): void
{
(new HandlerSessaoBanco($this->pdo))->write('sid', 'usuario_id|i:42;outro|s:1:"x";');
$stmt = $this->pdo->prepare('SELECT usuario_id FROM sessoes_dados WHERE id = :id');
$stmt->execute([':id' => 'sid']);
self::assertSame(42, (int) $stmt->fetchColumn());
}
}