Sessions e Cookies

Sessions e Cookies

O que fica guardado no navegador, o que fica no servidor e por que trocar isso de lugar custa caro. Com os atributos de cookie que importam, a regeneração de ID que barra session fixation e o preço escondido de amarrar a sessão ao endereço de quem acessa.
PHP

• • 30 min de leitura

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

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());
    }
}
Comentários

Mais em PHP

Orientação a Objetos: Fundamentos
Orientação a Objetos: Fundamentos

Uma classe define o molde; cada objeto guarda o próprio estado. Construtor…

Funções
Funções

Uma função guarda um trecho de lógica sob um nome e um contrato. Como declarar…

Estruturas de Controle
Estruturas de Controle

Decidir é o que separa um script de um programa. Como o PHP encadeia condições…