PHP Projeto Login com Sessão

Quase todo site tem uma parte que só aparece para quem entrou com e-mail e senha. Neste projeto você monta essa parte do zero: a página de cadastro, a de login, um painel que só abre para quem entrou e o botão de sair. E, como login é o alvo preferido de quem ataca sites, cada etapa fecha uma porta: senha guardada com password_hash, sessão renovada no login e formulários protegidos com token CSRF. Os ataques são mostrados de verdade, com o curl.

Você vai aprender
  • Organizar um projeto em vários arquivos com um comum.php incluído em todas as páginas.
  • Cadastrar usuários num banco SQLite, guardando só o hash da senha (password_hash).
  • Conferir a senha no login com password_verify e lembrar quem entrou com $_SESSION.
  • Proteger uma página (área restrita) e fazer o logout que apaga a sessão e o cookie.
  • Por que chamar session_regenerate_id(true) no login (fixação de sessão, demonstrada).
  • O que é CSRF e como um token escondido nos formulários impede o ataque.

Antes de começar

Este é o projeto mais "cheio" até aqui. Ele usa:

O que usamosAula
session_start, $_SESSION, o cookie PHPSESSIDSessões, Cookies
password_hash, password_verifySenhas (password_hash)
Token CSRF, hash_equalsCSRF e Segurança de Formulários
new PDO('sqlite:…'), prepare/executeConectar com PDO, Prepared Statements
requireinclude e require
header('Location: …')header e Redirecionamento

Também vale ter feito o Formulário de Contato (validação e Post/Redirect/Get).

Os arquivos do projeto

ArquivoPapelEtapa
banco.phpAbre o SQLite e cria a tabela usuarios1
comum.phpIncluído no topo de todas as páginas: sessão, banco e funções1, 2, 6
cadastro.phpFormulário "Criar conta"1
login.phpFormulário "Entrar"2, 5
painel.phpA área restrita2, 3
sair.phpO logout4, 6
index.phpManda o visitante para o painelcódigo completo
$ mkdir login
$ cd login
$ php -S localhost:8000
[Fri Oct 16 20:57:04 2026] PHP 8.5.8 Development Server (http://localhost:8000) started

Nas caixas "Experimente", cada arquivo é uma aba; o primeiro é a página que abre. O banco SQLite é criado na hora, dentro da caixa. Para o curl "lembrar" do login entre um comando e outro, usamos um pote de cookies: -c jar.txt guarda os cookies recebidos e -b jar.txt os envia de volta, como o navegador faz.


As etapas

  1. Etapa 1 · O banco e o cadastro

    Primeiro, o lugar onde os usuários moram. O banco.php abre (ou cria) o arquivo dados.sqlite e garante que a tabela existe:

    <?php
    // Abre o banco SQLite (o arquivo é criado na primeira vez)
    $pdo = new PDO('sqlite:' . __DIR__ . '/dados.sqlite');
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        nome       TEXT NOT NULL,
        email      TEXT NOT NULL UNIQUE,
        senha_hash TEXT NOT NULL,
        criado_em  TEXT NOT NULL
    )');
    
    // Só para testar: se não houver ninguém, cria a usuária de teste.
    // Apague este bloco num site de verdade.
    if ($pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn() == 0) {
        $teste = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
        $teste->execute(['Ana', 'ana@exemplo.com', password_hash('cafe-com-pao', PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
    }
    
    TrechoO que faz
    new PDO('sqlite:' . __DIR__ . '/dados.sqlite')Conecta ao banco SQLite, que é um arquivo comum. Se ele não existir, é criado. Não precisa instalar servidor de banco nenhum.
    PDO::FETCH_ASSOCAs linhas lidas vêm como arrays associativos: $usuario['nome'].
    CREATE TABLE IF NOT EXISTS usuariosCria a tabela só se ela ainda não existir. email … UNIQUE impede dois cadastros com o mesmo e-mail, até no nível do banco.
    senha_hash TEXTA coluna guarda o hash, nunca a senha.
    O bloco "Só para testar"Cria a usuária ana@exemplo.com com a senha cafe-com-pao quando a tabela está vazia, para você poder testar o login nas caixas. Num site de verdade, apague.

    O comum.php começa pequeno: fuso horário, banco e duas funções que todas as páginas vão usar.

    <?php
    // Incluído no topo de todas as páginas
    date_default_timezone_set('America/Sao_Paulo');
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function redirecionar(string $endereco): never
    {
        header('Location: ' . $endereco, true, 303);
        exit;
    }
    

    A redirecionar() junta o header('Location: …', true, 303) e o exit do Post/Redirect/Get numa linha só. O tipo de retorno never PHP 8.1+ diz "esta função nunca volta" (ela sempre termina o script); em versões antigas, tire o : never.

    E o cadastro. A estrutura é a do formulário de contato; a novidade está no fim do bloco PHP:

    <?php
    require __DIR__ . '/comum.php';
    
    $nome  = trim($_POST['nome'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $erros = [];
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (mb_strlen($nome) < 2) {
            $erros[] = 'Escreva o seu nome.';
        }
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            $erros[] = 'Esse e-mail não parece válido.';
        }
        if (mb_strlen($senha) < 8) {
            $erros[] = 'A senha precisa ter pelo menos 8 caracteres.';
        }
    
        if (!$erros) {
            $busca = $pdo->prepare('SELECT id FROM usuarios WHERE email = ?');
            $busca->execute([$email]);
            if ($busca->fetch()) {
                $erros[] = 'Já existe uma conta com esse e-mail.';
            }
        }
    
        if (!$erros) {
            $novo = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
            $novo->execute([$nome, $email, password_hash($senha, PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
            redirecionar('login.php?cadastrado=1');
        }
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Criar conta</title></head>
    <body>
    <h1>Criar conta</h1>
    <?php foreach ($erros as $erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endforeach; ?>
    <form method="post" action="cadastro.php">
      <p><label>Nome<br><input name="nome" value="<?= e($nome) ?>"></label></p>
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha (mínimo 8 caracteres)<br><input type="password" name="senha"></label></p>
      <p><button>Criar conta</button></p>
    </form>
    <p>Já tem conta? <a href="login.php">Entrar</a></p>
    </body>
    </html>
    
    $pdo->prepare('… WHERE email = ?')
    Prepara a consulta com um ? no lugar do e-mail. O valor nunca é colado no SQL: vai separado no execute. É isso que impede a injeção de SQL (veja Prepared Statements).
    $busca->execute([$email])
    Roda a consulta, trocando o ? pelo e-mail.
    if ($busca->fetch())
    fetch devolve a linha encontrada ou false. Achou alguém? Então o e-mail já tem conta.
    password_hash($senha, PASSWORD_DEFAULT)
    Transforma a senha num hash: um texto do qual não dá para voltar à senha. PASSWORD_DEFAULT usa o melhor algoritmo da versão do PHP (hoje, bcrypt com custo 12).
    type="password"
    O navegador mostra bolinhas. E repare: o campo de senha não volta preenchido quando há erro; senha não deve circular de volta pela página.
    $ curl -s -d 'nome=B&email=beto&senha=123' localhost:8000/cadastro.php | grep erro
      <p class="erro">Escreva o seu nome.</p>
      <p class="erro">Esse e-mail não parece válido.</p>
      <p class="erro">A senha precisa ter pelo menos 8 caracteres.</p>
    $ curl -si -d 'nome=Beto Souza&email=beto@exemplo.com&senha=pao-de-queijo' localhost:8000/cadastro.php | grep -E 'HTTP|Location'
    HTTP/1.1 303 See Other
    Location: login.php?cadastrado=1                  « o login.php chega na etapa 2
    $ curl -s -d 'nome=Beto de Novo&email=beto@exemplo.com&senha=outra-senha' localhost:8000/cadastro.php | grep erro
      <p class="erro">Já existe uma conta com esse e-mail.</p>
    $ sqlite3 dados.sqlite 'SELECT id, nome, email, senha_hash FROM usuarios'
    1|Ana|ana@exemplo.com|$2y$12$P/cr98ewf1pXO6FtmIaWdu/YMCHeWasABQ21H/2MytAkCK/NPHFFy
    2|Beto Souza|beto@exemplo.com|$2y$12$FVXg1aRzIZCmCfKCzfyUHuoO/0sFJSpC.LGkNBrXP9HJALTWlmbNa

    Olhe a última coluna: nem a Ana nem o Beto têm a senha no banco. Se alguém roubar o arquivo dados.sqlite, não vai ler pao-de-queijo em lugar nenhum. O hash começa com $2y$ (o algoritmo bcrypt), depois 12$ (o custo: quanto maior, mais lento para quem tenta adivinhar) e o resto mistura um "sal" aleatório com o resultado. Por causa do sal, a mesma senha gera hashes diferentes a cada vez:

    <?php
    $h1 = password_hash('cafe-com-pao', PASSWORD_DEFAULT);
    $h2 = password_hash('cafe-com-pao', PASSWORD_DEFAULT);
    echo $h1, "\n";
    echo $h2, "\n";
    var_dump(password_verify('cafe-com-pao', $h1));
    var_dump(password_verify('Cafe-com-pao', $h1));
    var_dump(password_verify('cafe-com-pao', $h2));
    
    $ php hash.php
    $2y$12$WvFEKacFV4ZWYfsOxMQMwOg36feQMGqAm8AEB3F/XCNuhw9gWCcqO
    $2y$12$C96MRdzOZ1jrv.embQOvIep21RD1VNNI4GIlCffurdTQouZ.HF7Bu      « mesma senha, outro hash
    bool(true)
    bool(false)                                                         « uma letra maiúscula muda tudo
    bool(true)

    É por isso que não dá para comparar password_hash($senha) == $hash_do_banco: quem confere é o password_verify, que lê o sal de dentro do hash. Mais detalhes em Senhas.

    Missão: acrescente o campo Repita a senha (name="confirmar", type="password") e a regra: se $senha !== $confirmar, o erro é As senhas não conferem.. A caixa já envia duas senhas diferentes (repare no "quejo").

    <?php
    require __DIR__ . '/comum.php';
    
    $nome  = trim($_POST['nome'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $erros = [];
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (mb_strlen($nome) < 2) {
            $erros[] = 'Escreva o seu nome.';
        }
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            $erros[] = 'Esse e-mail não parece válido.';
        }
        if (mb_strlen($senha) < 8) {
            $erros[] = 'A senha precisa ter pelo menos 8 caracteres.';
        }
    
        if (!$erros) {
            $busca = $pdo->prepare('SELECT id FROM usuarios WHERE email = ?');
            $busca->execute([$email]);
            if ($busca->fetch()) {
                $erros[] = 'Já existe uma conta com esse e-mail.';
            }
        }
    
        if (!$erros) {
            $novo = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
            $novo->execute([$nome, $email, password_hash($senha, PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
            redirecionar('login.php?cadastrado=1');
        }
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Criar conta</title></head>
    <body>
    <h1>Criar conta</h1>
    <?php foreach ($erros as $erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endforeach; ?>
    <form method="post" action="cadastro.php">
      <p><label>Nome<br><input name="nome" value="<?= e($nome) ?>"></label></p>
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha (mínimo 8 caracteres)<br><input type="password" name="senha"></label></p>
      <p><button>Criar conta</button></p>
    </form>
    <p>Já tem conta? <a href="login.php">Entrar</a></p>
    </body>
    </html>
    
    <?php
    // Incluído no topo de todas as páginas
    date_default_timezone_set('America/Sao_Paulo');
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function redirecionar(string $endereco): never
    {
        header('Location: ' . $endereco, true, 303);
        exit;
    }
    
    <?php
    // Abre o banco SQLite (o arquivo é criado na primeira vez)
    $pdo = new PDO('sqlite:' . __DIR__ . '/dados.sqlite');
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        nome       TEXT NOT NULL,
        email      TEXT NOT NULL UNIQUE,
        senha_hash TEXT NOT NULL,
        criado_em  TEXT NOT NULL
    )');
    
    // Só para testar: se não houver ninguém, cria a usuária de teste.
    // Apague este bloco num site de verdade.
    if ($pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn() == 0) {
        $teste = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
        $teste->execute(['Ana', 'ana@exemplo.com', password_hash('cafe-com-pao', PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
    }
    
    <?php
    require __DIR__ . '/comum.php';
    
    $nome  = trim($_POST['nome'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $confirmar = $_POST['confirmar'] ?? '';
    $erros = [];
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (mb_strlen($nome) < 2) {
            $erros[] = 'Escreva o seu nome.';
        }
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            $erros[] = 'Esse e-mail não parece válido.';
        }
        if (mb_strlen($senha) < 8) {
            $erros[] = 'A senha precisa ter pelo menos 8 caracteres.';
        }
        if ($senha !== $confirmar) {
            $erros[] = 'As senhas não conferem.';
        }
    
        if (!$erros) {
            $busca = $pdo->prepare('SELECT id FROM usuarios WHERE email = ?');
            $busca->execute([$email]);
            if ($busca->fetch()) {
                $erros[] = 'Já existe uma conta com esse e-mail.';
            }
        }
    
        if (!$erros) {
            $novo = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
            $novo->execute([$nome, $email, password_hash($senha, PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
            redirecionar('login.php?cadastrado=1');
        }
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Criar conta</title></head>
    <body>
    <h1>Criar conta</h1>
    <?php foreach ($erros as $erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endforeach; ?>
    <form method="post" action="cadastro.php">
      <p><label>Nome<br><input name="nome" value="<?= e($nome) ?>"></label></p>
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha (mínimo 8 caracteres)<br><input type="password" name="senha"></label></p>
      <p><label>Repita a senha<br><input type="password" name="confirmar"></label></p>
      <p><button>Criar conta</button></p>
    </form>
    <p>Já tem conta? <a href="login.php">Entrar</a></p>
    </body>
    </html>
    
  2. Etapa 2 · O login e a sessão

    Para o site "lembrar" que a Ana entrou, usamos a sessão: o PHP guarda dados no servidor e entrega ao navegador só um cookie com um número de identificação, o PHPSESSID. A cada pedido, o navegador devolve o cookie e o PHP reabre a gaveta certa. O comum.php passa a abrir a sessão:

    date_default_timezone_set('America/Sao_Paulo');
    session_start([
        'cookie_httponly' => true,
        'cookie_samesite' => 'Lax',
    ]);
    require __DIR__ . '/banco.php';
    session_start(
    Abre a sessão: lê o cookie PHPSESSID (ou cria um novo) e enche o array $_SESSION. Precisa vir antes de qualquer saída, como o header().
    'cookie_httponly' => true
    O cookie não pode ser lido por JavaScript. Se um dia alguém conseguir injetar um script na página, ele não rouba a sessão.
    'cookie_samesite' => 'Lax'
    O navegador não envia o cookie em POSTs que vêm de outros sites. É uma primeira barreira contra CSRF (etapa 6).

    O login.php:

    <?php
    require __DIR__ . '/comum.php';
    
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $erro = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $busca = $pdo->prepare('SELECT id, nome, senha_hash FROM usuarios WHERE email = ?');
        $busca->execute([$email]);
        $usuario = $busca->fetch();
    
        if ($usuario && password_verify($senha, $usuario['senha_hash'])) {
            $_SESSION['usuario'] = ['id' => $usuario['id'], 'nome' => $usuario['nome']];
            redirecionar('painel.php');
        }
        $erro = 'E-mail ou senha incorretos.';
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Entrar</title></head>
    <body>
    <h1>Entrar</h1>
    <?php if (isset($_GET['cadastrado'])): ?>
      <p class="sucesso">Conta criada! Agora é só entrar.</p>
    <?php endif; ?>
    <?php if (isset($_GET['saiu'])): ?>
      <p class="sucesso">Você saiu. Até logo!</p>
    <?php endif; ?>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    <form method="post" action="login.php">
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha<br><input type="password" name="senha"></label></p>
      <p><button>Entrar</button></p>
    </form>
    <p>Não tem conta? <a href="cadastro.php">Crie uma</a></p>
    </body>
    </html>
    
    TrechoO que faz
    SELECT id, nome, senha_hash … WHERE email = ?Busca a pessoa pelo e-mail, com prepared statement.
    $usuario && password_verify($senha, $usuario['senha_hash'])Duas condições: o e-mail existe e a senha confere com o hash. Se o e-mail não existir, $usuario é false e o && nem chama o password_verify.
    $_SESSION['usuario'] = ['id' => …, 'nome' => …]Guarda na sessão quem entrou. Só o necessário: nada de hash de senha na sessão.
    redirecionar('painel.php')Post/Redirect/Get: depois do login, vai para o painel.
    'E-mail ou senha incorretos.'A mesma mensagem para e-mail inexistente e senha errada. Dizer "este e-mail não existe" ensinaria a um atacante quais e-mails têm conta.

    E o primeiro painel.php, que mostra o nome guardado na sessão:

    <?php
    require __DIR__ . '/comum.php';
    
    $usuario = $_SESSION['usuario'];
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Painel</title></head>
    <body>
    <h1>Olá, <?= e($usuario['nome']) ?>!</h1>
    <p>Esta é a área restrita: só aparece para quem entrou.</p>
    </body>
    </html>
    
    $ curl -s -c jar.txt -b jar.txt -d 'email=ana@exemplo.com&senha=errada' localhost:8000/login.php | grep -E 'erro|value='
      <p class="erro">E-mail ou senha incorretos.</p>
      <p><label>E-mail<br><input name="email" value="ana@exemplo.com"></label></p>
    $ curl -s -c jar.txt -b jar.txt -d 'email=ninguem@exemplo.com&senha=qualquer' localhost:8000/login.php | grep -E 'erro'
      <p class="erro">E-mail ou senha incorretos.</p>          « mesma mensagem: não entrega nada
    $ curl -si -c jar.txt -b jar.txt -d 'email=ana@exemplo.com&senha=cafe-com-pao' localhost:8000/login.php | grep -E 'HTTP|Location|Set-Cookie'
    HTTP/1.1 303 See Other
    Location: painel.php
    $ cat jar.txt
    # Netscape HTTP Cookie File
    # https://curl.se/docs/http-cookies.html
    # This file was generated by libcurl! Edit at your own risk.
    
    #HttpOnly_localhost	FALSE	/	FALSE	0	PHPSESSID	e079bc890e095f5ec6e3b451983fe999
    $ curl -s -b jar.txt localhost:8000/painel.php | grep h1
    <h1>Olá, Ana!</h1>

    O cookie PHPSESSID é a "pulseira" da Ana: com ele, o painel sabe quem ela é. Mas veja o que acontece com quem chega sem pulseira:

    $ curl -s localhost:8000/painel.php
    <br />
    <b>Warning</b>:  Undefined array key "usuario" in <b>/home/ana/login/painel.php</b> on line <b>4</b><br />
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Painel</title></head>
    <body>
    <h1>Olá, <br />
    <b>Warning</b>:  Trying to access array offset on null in <b>/home/ana/login/painel.php</b> on line <b>10</b><br />
    <br />
    <b>Fatal error</b>:  Uncaught TypeError: e(): Argument #1 ($texto) must be of type string, null given, called in /home/ana/login/painel.php on line 10 and defined in /home/ana/login/comum.php:10
    Stack trace:
    #0 /home/ana/login/painel.php(10): e(NULL)
    #1 {main}
      thrown in <b>/home/ana/login/comum.php</b> on line <b>10</b><br />

    Três problemas em cascata: $_SESSION['usuario'] não existe (o Warning da linha 4), então $usuario é null; ler ['nome'] de um null dá outro Warning; e o e(), que exige texto, recebe null e para tudo com um TypeError. O painel "funcionou" para estranhos, só que quebrado. A próxima etapa tranca a porta.

    Missão: a caixa já faz o login da Ana e segue para o painel. Guarde na sessão a hora do login ($_SESSION['entrou_em'] = date('H:i'); no login.php) e mostre no painel: Você entrou às 20:59. (com a sua hora).

    <?php
    require __DIR__ . '/comum.php';
    
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $erro = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $busca = $pdo->prepare('SELECT id, nome, senha_hash FROM usuarios WHERE email = ?');
        $busca->execute([$email]);
        $usuario = $busca->fetch();
    
        if ($usuario && password_verify($senha, $usuario['senha_hash'])) {
            $_SESSION['usuario'] = ['id' => $usuario['id'], 'nome' => $usuario['nome']];
            redirecionar('painel.php');
        }
        $erro = 'E-mail ou senha incorretos.';
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Entrar</title></head>
    <body>
    <h1>Entrar</h1>
    <?php if (isset($_GET['cadastrado'])): ?>
      <p class="sucesso">Conta criada! Agora é só entrar.</p>
    <?php endif; ?>
    <?php if (isset($_GET['saiu'])): ?>
      <p class="sucesso">Você saiu. Até logo!</p>
    <?php endif; ?>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    <form method="post" action="login.php">
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha<br><input type="password" name="senha"></label></p>
      <p><button>Entrar</button></p>
    </form>
    <p>Não tem conta? <a href="cadastro.php">Crie uma</a></p>
    </body>
    </html>
    
    <?php
    require __DIR__ . '/comum.php';
    
    $usuario = $_SESSION['usuario'];
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Painel</title></head>
    <body>
    <h1>Olá, <?= e($usuario['nome']) ?>!</h1>
    <p>Esta é a área restrita: só aparece para quem entrou.</p>
    </body>
    </html>
    
    <?php
    // Incluído no topo de todas as páginas
    date_default_timezone_set('America/Sao_Paulo');
    session_start([
        'cookie_httponly' => true,
        'cookie_samesite' => 'Lax',
    ]);
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function redirecionar(string $endereco): never
    {
        header('Location: ' . $endereco, true, 303);
        exit;
    }
    
    <?php
    // Abre o banco SQLite (o arquivo é criado na primeira vez)
    $pdo = new PDO('sqlite:' . __DIR__ . '/dados.sqlite');
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        nome       TEXT NOT NULL,
        email      TEXT NOT NULL UNIQUE,
        senha_hash TEXT NOT NULL,
        criado_em  TEXT NOT NULL
    )');
    
    // Só para testar: se não houver ninguém, cria a usuária de teste.
    // Apague este bloco num site de verdade.
    if ($pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn() == 0) {
        $teste = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
        $teste->execute(['Ana', 'ana@exemplo.com', password_hash('cafe-com-pao', PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
    }
    
    <?php
    require __DIR__ . '/comum.php';
    
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $erro = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $busca = $pdo->prepare('SELECT id, nome, senha_hash FROM usuarios WHERE email = ?');
        $busca->execute([$email]);
        $usuario = $busca->fetch();
    
        if ($usuario && password_verify($senha, $usuario['senha_hash'])) {
            $_SESSION['usuario'] = ['id' => $usuario['id'], 'nome' => $usuario['nome']];
            $_SESSION['entrou_em'] = date('H:i');
            redirecionar('painel.php');
        }
        $erro = 'E-mail ou senha incorretos.';
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Entrar</title></head>
    <body>
    <h1>Entrar</h1>
    <?php if (isset($_GET['cadastrado'])): ?>
      <p class="sucesso">Conta criada! Agora é só entrar.</p>
    <?php endif; ?>
    <?php if (isset($_GET['saiu'])): ?>
      <p class="sucesso">Você saiu. Até logo!</p>
    <?php endif; ?>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    <form method="post" action="login.php">
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha<br><input type="password" name="senha"></label></p>
      <p><button>Entrar</button></p>
    </form>
    <p>Não tem conta? <a href="cadastro.php">Crie uma</a></p>
    </body>
    </html>
    
    <?php
    require __DIR__ . '/comum.php';
    
    $usuario = $_SESSION['usuario'];
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Painel</title></head>
    <body>
    <h1>Olá, <?= e($usuario['nome']) ?>!</h1>
    <p>Esta é a área restrita: só aparece para quem entrou.</p>
    <p>Você entrou às <?= e($_SESSION['entrou_em']) ?>.</p>
    </body>
    </html>
    
  3. Etapa 3 · A área restrita

    Trancar uma página é simples: logo no começo, antes de mostrar qualquer coisa, pergunte "tem alguém logado?". Se não tiver, mande para o login e pare.

    &lt;?php
    require __DIR__ . '/comum.php';
    
    if (!isset($_SESSION['usuario'])) {
        redirecionar('login.php');
    }
    $usuario = $_SESSION['usuario'];
    ?&gt;

    O exit dentro do redirecionar() é o que realmente tranca: sem ele, o PHP mandaria o cabeçalho de redirecionamento e continuaria gerando o painel. Um navegador obedeceria e mudaria de página, mas o curl (ou qualquer robô) leria o conteúdo secreto na resposta.

    $ curl -si localhost:8000/painel.php | grep -E 'HTTP|Location'
    HTTP/1.1 303 See Other
    Location: login.php
    $ curl -sL localhost:8000/painel.php | grep h1
    <h1>Entrar</h1>                « seguindo o redirecionamento, cai no login

    Toda página restrita repete essas três linhas. Num projeto maior, elas viram uma função no comum.php, e é isso que a missão pede.

    Missão: crie no comum.php a função exigir_login(): array, que redireciona para o login quando não há usuário e, se houver, devolve $_SESSION['usuario']. No painel, troque o if e a linha seguinte por $usuario = exigir_login();. A caixa abre o painel sem login: o resultado deve ser a página Entrar.

    <?php
    require __DIR__ . '/comum.php';
    
    if (!isset($_SESSION['usuario'])) {
        redirecionar('login.php');
    }
    $usuario = $_SESSION['usuario'];
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Painel</title></head>
    <body>
    <h1>Olá, <?= e($usuario['nome']) ?>!</h1>
    <p>Esta é a área restrita: só aparece para quem entrou.</p>
    </body>
    </html>
    
    <?php
    // Incluído no topo de todas as páginas
    date_default_timezone_set('America/Sao_Paulo');
    session_start([
        'cookie_httponly' => true,
        'cookie_samesite' => 'Lax',
    ]);
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function redirecionar(string $endereco): never
    {
        header('Location: ' . $endereco, true, 303);
        exit;
    }
    
    <?php
    require __DIR__ . '/comum.php';
    
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $erro = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $busca = $pdo->prepare('SELECT id, nome, senha_hash FROM usuarios WHERE email = ?');
        $busca->execute([$email]);
        $usuario = $busca->fetch();
    
        if ($usuario && password_verify($senha, $usuario['senha_hash'])) {
            $_SESSION['usuario'] = ['id' => $usuario['id'], 'nome' => $usuario['nome']];
            redirecionar('painel.php');
        }
        $erro = 'E-mail ou senha incorretos.';
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Entrar</title></head>
    <body>
    <h1>Entrar</h1>
    <?php if (isset($_GET['cadastrado'])): ?>
      <p class="sucesso">Conta criada! Agora é só entrar.</p>
    <?php endif; ?>
    <?php if (isset($_GET['saiu'])): ?>
      <p class="sucesso">Você saiu. Até logo!</p>
    <?php endif; ?>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    <form method="post" action="login.php">
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha<br><input type="password" name="senha"></label></p>
      <p><button>Entrar</button></p>
    </form>
    <p>Não tem conta? <a href="cadastro.php">Crie uma</a></p>
    </body>
    </html>
    
    <?php
    // Abre o banco SQLite (o arquivo é criado na primeira vez)
    $pdo = new PDO('sqlite:' . __DIR__ . '/dados.sqlite');
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        nome       TEXT NOT NULL,
        email      TEXT NOT NULL UNIQUE,
        senha_hash TEXT NOT NULL,
        criado_em  TEXT NOT NULL
    )');
    
    // Só para testar: se não houver ninguém, cria a usuária de teste.
    // Apague este bloco num site de verdade.
    if ($pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn() == 0) {
        $teste = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
        $teste->execute(['Ana', 'ana@exemplo.com', password_hash('cafe-com-pao', PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
    }
    
    <?php
    require __DIR__ . '/comum.php';
    
    $usuario = exigir_login();
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Painel</title></head>
    <body>
    <h1>Olá, <?= e($usuario['nome']) ?>!</h1>
    <p>Esta é a área restrita: só aparece para quem entrou.</p>
    </body>
    </html>
    
    <?php
    // Incluído no topo de todas as páginas
    date_default_timezone_set('America/Sao_Paulo');
    session_start([
        'cookie_httponly' => true,
        'cookie_samesite' => 'Lax',
    ]);
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function redirecionar(string $endereco): never
    {
        header('Location: ' . $endereco, true, 303);
        exit;
    }
    
    function exigir_login(): array
    {
        if (!isset($_SESSION['usuario'])) {
            redirecionar('login.php');
        }
        return $_SESSION['usuario'];
    }
    
  4. Etapa 4 · Sair (logout)

    Sair é desfazer o login: esvaziar a sessão, apagar o cookie do navegador e destruir a "gaveta" no servidor. Por enquanto, um link simples no painel leva ao sair.php:

    <p><a href="sair.php">Sair</a></p>
    <?php
    require __DIR__ . '/comum.php';
    
    $_SESSION = [];
    $p = session_get_cookie_params();
    setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
    session_destroy();
    
    redirecionar('login.php?saiu=1');
    
    $_SESSION = [];
    Esvazia os dados da sessão (nesta requisição e no que será gravado).
    session_get_cookie_params()
    Lê como o cookie da sessão foi criado (caminho, domínio…). O cookie só é apagado se for "o mesmo".
    setcookie(session_name(), '', time() - 3600, …)
    Manda um cookie PHPSESSID vazio com validade no passado: o navegador entende "apague".
    session_destroy()
    Apaga o arquivo da sessão no servidor.
    $ curl -s -c jar.txt -b jar.txt -d 'email=ana@exemplo.com&senha=cafe-com-pao' -L localhost:8000/login.php | grep -E 'h1|Sair'
    <h1>Olá, Ana!</h1>
    <p><a href="sair.php">Sair</a></p>
    $ grep -c PHPSESSID jar.txt
    1
    $ curl -si -c jar.txt -b jar.txt localhost:8000/sair.php | grep -E 'HTTP|Location|Set-Cookie'
    HTTP/1.1 303 See Other
    Set-Cookie: PHPSESSID=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/; HttpOnly
    Location: login.php?saiu=1
    $ grep -c PHPSESSID jar.txt
    0                                « o cookie sumiu do pote

    O Set-Cookie: PHPSESSID=deleted; expires=Thu, 01 Jan 1970… é o pedido de "apague este cookie" (o PHP escreve deleted e uma data de 1970). Depois disso, o painel volta a mandar para o login.

    Sair por um link tem um problema

    Qualquer página da internet pode conter <img src="http://localhost:8000/sair.php">. Quando a Ana abrir essa página, o navegador dela "carrega a imagem", isto é, visita o sair.php, e ela é deslogada sem clicar em nada. Deslogar é inofensivo, mas imagine se o link fosse "apagar minha conta". Ações que mudam algo devem ser POST com token: é a etapa 6.

    Missão: conte as visitas ao painel na sessão: $_SESSION['visitas'] = ($_SESSION['visitas'] ?? 0) + 1; e mostre Visitas nesta sessão: N. Depois, na aba Resultado, recarregue o painel algumas vezes, clique em Sair, entre de novo (ana@exemplo.com / cafe-com-pao) e veja o contador recomeçar do 1.

    <?php
    require __DIR__ . '/comum.php';
    
    if (!isset($_SESSION['usuario'])) {
        redirecionar('login.php');
    }
    $usuario = $_SESSION['usuario'];
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Painel</title></head>
    <body>
    <h1>Olá, <?= e($usuario['nome']) ?>!</h1>
    <p>Esta é a área restrita: só aparece para quem entrou.</p>
    <p><a href="sair.php">Sair</a></p>
    </body>
    </html>
    
    <?php
    require __DIR__ . '/comum.php';
    
    $_SESSION = [];
    $p = session_get_cookie_params();
    setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
    session_destroy();
    
    redirecionar('login.php?saiu=1');
    
    <?php
    require __DIR__ . '/comum.php';
    
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $erro = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $busca = $pdo->prepare('SELECT id, nome, senha_hash FROM usuarios WHERE email = ?');
        $busca->execute([$email]);
        $usuario = $busca->fetch();
    
        if ($usuario && password_verify($senha, $usuario['senha_hash'])) {
            $_SESSION['usuario'] = ['id' => $usuario['id'], 'nome' => $usuario['nome']];
            redirecionar('painel.php');
        }
        $erro = 'E-mail ou senha incorretos.';
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Entrar</title></head>
    <body>
    <h1>Entrar</h1>
    <?php if (isset($_GET['cadastrado'])): ?>
      <p class="sucesso">Conta criada! Agora é só entrar.</p>
    <?php endif; ?>
    <?php if (isset($_GET['saiu'])): ?>
      <p class="sucesso">Você saiu. Até logo!</p>
    <?php endif; ?>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    <form method="post" action="login.php">
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha<br><input type="password" name="senha"></label></p>
      <p><button>Entrar</button></p>
    </form>
    <p>Não tem conta? <a href="cadastro.php">Crie uma</a></p>
    </body>
    </html>
    
    <?php
    // Incluído no topo de todas as páginas
    date_default_timezone_set('America/Sao_Paulo');
    session_start([
        'cookie_httponly' => true,
        'cookie_samesite' => 'Lax',
    ]);
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function redirecionar(string $endereco): never
    {
        header('Location: ' . $endereco, true, 303);
        exit;
    }
    
    <?php
    // Abre o banco SQLite (o arquivo é criado na primeira vez)
    $pdo = new PDO('sqlite:' . __DIR__ . '/dados.sqlite');
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        nome       TEXT NOT NULL,
        email      TEXT NOT NULL UNIQUE,
        senha_hash TEXT NOT NULL,
        criado_em  TEXT NOT NULL
    )');
    
    // Só para testar: se não houver ninguém, cria a usuária de teste.
    // Apague este bloco num site de verdade.
    if ($pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn() == 0) {
        $teste = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
        $teste->execute(['Ana', 'ana@exemplo.com', password_hash('cafe-com-pao', PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
    }
    
    <?php
    require __DIR__ . '/comum.php';
    
    if (!isset($_SESSION['usuario'])) {
        redirecionar('login.php');
    }
    $usuario = $_SESSION['usuario'];
    $_SESSION['visitas'] = ($_SESSION['visitas'] ?? 0) + 1;
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Painel</title></head>
    <body>
    <h1>Olá, <?= e($usuario['nome']) ?>!</h1>
    <p>Esta é a área restrita: só aparece para quem entrou.</p>
    <p>Visitas nesta sessão: <?= $_SESSION['visitas'] ?></p>
    <p><a href="sair.php">Sair</a></p>
    </body>
    </html>
    
  5. Etapa 5 · session_regenerate_id: um crachá novo a cada login

    O sistema funciona, mas tem um furo clássico chamado fixação de sessão. A ideia do ataque: o atacante escolhe um número de sessão, dá um jeito de a vítima usar esse número (um link preparado, um computador compartilhado) e espera ela fazer login. Como o número não muda no login, o atacante, que sabe o número, entra junto.

    No laboratório, com a versão da etapa 4, o "atacante" inventou o número idescolhidopelatacante. A Ana faz login usando esse cookie (primeiro comando); depois o atacante usa o mesmo cookie (segundo comando):

    $ curl -si -b 'PHPSESSID=idescolhidopelatacante' -d 'email=ana@exemplo.com&senha=cafe-com-pao' localhost:8000/login.php | grep -E 'HTTP|Location|Set-Cookie'
    HTTP/1.1 303 See Other
    Location: painel.php                         « nenhum cookie novo: o número continua o mesmo
    $ curl -s -b 'PHPSESSID=idescolhidopelatacante' localhost:8000/painel.php | grep h1
    <h1>Olá, Ana!</h1>                           « o atacante está dentro da conta da Ana

    A correção é uma linha no login.php, logo depois de conferir a senha:

        if ($usuario && password_verify($senha, $usuario['senha_hash'])) {
            session_regenerate_id(true);
            $_SESSION['usuario'] = ['id' => $usuario['id'], 'nome' => $usuario['nome']];
            redirecionar('painel.php');
        }
    session_regenerate_id
    "Gerar de novo o identificador da sessão": troca o número, mantendo os dados, e manda o cookie novo ao navegador.
    (true)
    Apaga a sessão antiga no servidor. Sem o true, o número velho continuaria valendo por um tempo.
    $ curl -si -b 'PHPSESSID=idescolhidopelatacante' -d 'email=ana@exemplo.com&senha=cafe-com-pao' localhost:8000/login.php | grep -E 'HTTP|Location|Set-Cookie'
    HTTP/1.1 303 See Other
    Set-Cookie: PHPSESSID=11ef8e1494f2daf925a5c6a3673fd61e; path=/; HttpOnly; SameSite=Lax      « crachá novo para a Ana
    Location: painel.php
    $ curl -si -b 'PHPSESSID=idescolhidopelatacante' localhost:8000/painel.php | grep -E 'HTTP|Location'
    HTTP/1.1 303 See Other
    Location: login.php                          « o número do atacante não vale mais nada

    Regra para levar: sempre que o nível de acesso muda (login, troca de senha, virar administrador), chame session_regenerate_id(true).

    Missão: o login.php desta caixa é o da etapa 4, ainda sem a proteção. Acrescente o session_regenerate_id(true); no lugar certo e confira que o login continua funcionando (o painel deve mostrar Olá, Ana!).

    <?php
    require __DIR__ . '/comum.php';
    
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $erro = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $busca = $pdo->prepare('SELECT id, nome, senha_hash FROM usuarios WHERE email = ?');
        $busca->execute([$email]);
        $usuario = $busca->fetch();
    
        if ($usuario && password_verify($senha, $usuario['senha_hash'])) {
            $_SESSION['usuario'] = ['id' => $usuario['id'], 'nome' => $usuario['nome']];
            redirecionar('painel.php');
        }
        $erro = 'E-mail ou senha incorretos.';
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Entrar</title></head>
    <body>
    <h1>Entrar</h1>
    <?php if (isset($_GET['cadastrado'])): ?>
      <p class="sucesso">Conta criada! Agora é só entrar.</p>
    <?php endif; ?>
    <?php if (isset($_GET['saiu'])): ?>
      <p class="sucesso">Você saiu. Até logo!</p>
    <?php endif; ?>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    <form method="post" action="login.php">
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha<br><input type="password" name="senha"></label></p>
      <p><button>Entrar</button></p>
    </form>
    <p>Não tem conta? <a href="cadastro.php">Crie uma</a></p>
    </body>
    </html>
    
    <?php
    require __DIR__ . '/comum.php';
    
    if (!isset($_SESSION['usuario'])) {
        redirecionar('login.php');
    }
    $usuario = $_SESSION['usuario'];
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Painel</title></head>
    <body>
    <h1>Olá, <?= e($usuario['nome']) ?>!</h1>
    <p>Esta é a área restrita: só aparece para quem entrou.</p>
    <p><a href="sair.php">Sair</a></p>
    </body>
    </html>
    
    <?php
    // Incluído no topo de todas as páginas
    date_default_timezone_set('America/Sao_Paulo');
    session_start([
        'cookie_httponly' => true,
        'cookie_samesite' => 'Lax',
    ]);
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function redirecionar(string $endereco): never
    {
        header('Location: ' . $endereco, true, 303);
        exit;
    }
    
    <?php
    // Abre o banco SQLite (o arquivo é criado na primeira vez)
    $pdo = new PDO('sqlite:' . __DIR__ . '/dados.sqlite');
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        nome       TEXT NOT NULL,
        email      TEXT NOT NULL UNIQUE,
        senha_hash TEXT NOT NULL,
        criado_em  TEXT NOT NULL
    )');
    
    // Só para testar: se não houver ninguém, cria a usuária de teste.
    // Apague este bloco num site de verdade.
    if ($pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn() == 0) {
        $teste = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
        $teste->execute(['Ana', 'ana@exemplo.com', password_hash('cafe-com-pao', PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
    }
    
    <?php
    require __DIR__ . '/comum.php';
    
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $erro = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $busca = $pdo->prepare('SELECT id, nome, senha_hash FROM usuarios WHERE email = ?');
        $busca->execute([$email]);
        $usuario = $busca->fetch();
    
        if ($usuario && password_verify($senha, $usuario['senha_hash'])) {
            session_regenerate_id(true);
            $_SESSION['usuario'] = ['id' => $usuario['id'], 'nome' => $usuario['nome']];
            redirecionar('painel.php');
        }
        $erro = 'E-mail ou senha incorretos.';
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Entrar</title></head>
    <body>
    <h1>Entrar</h1>
    <?php if (isset($_GET['cadastrado'])): ?>
      <p class="sucesso">Conta criada! Agora é só entrar.</p>
    <?php endif; ?>
    <?php if (isset($_GET['saiu'])): ?>
      <p class="sucesso">Você saiu. Até logo!</p>
    <?php endif; ?>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    <form method="post" action="login.php">
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha<br><input type="password" name="senha"></label></p>
      <p><button>Entrar</button></p>
    </form>
    <p>Não tem conta? <a href="cadastro.php">Crie uma</a></p>
    </body>
    </html>
    
  6. Etapa 6 · Token CSRF nos formulários

    CSRF (Cross-Site Request Forgery, "falsificação de pedido entre sites") é quando outro site faz o navegador da vítima enviar um formulário para o seu site. O navegador manda junto o cookie da sessão, e o seu site acha que foi a própria pessoa. A defesa: cada formulário leva um número secreto (o token) que só o seu site conhece, e todo POST sem o número certo é recusado.

    Duas funções novas no comum.php:

    function token_csrf(): string
    {
        if (empty($_SESSION['csrf'])) {
            $_SESSION['csrf'] = bin2hex(random_bytes(32));
        }
        return $_SESSION['csrf'];
    }
    
    function conferir_csrf(): void
    {
        $enviado = $_POST['csrf'] ?? '';
        if (!is_string($enviado) || !hash_equals(token_csrf(), $enviado)) {
            http_response_code(403);
            exit('Formulário expirado ou inválido. Volte, recarregue a página e tente de novo.');
        }
    }
    TrechoO que faz
    bin2hex(random_bytes(32))32 bytes aleatórios, próprios para segurança, escritos como 64 letras e números. Impossível de adivinhar.
    $_SESSION['csrf']O token fica guardado na sessão: um por pessoa, criado na primeira vez que é pedido.
    hash_equals(a, b)Compara dois segredos levando sempre o mesmo tempo. Um === comum para no primeiro caractere diferente, e a diferença de tempo pode ajudar um atacante a descobrir o token aos poucos.
    http_response_code(403)Responde "403 Forbidden" (proibido).

    Todo formulário ganha um campo escondido com o token, e todo arquivo que recebe POST chama conferir_csrf() antes de fazer qualquer coisa:

    <form method="post" action="login.php">
      <input type="hidden" name="csrf" value="<?= token_csrf() ?>">
      ...
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        conferir_csrf();
        ...

    E o sair vira um formulário POST com token; um GET no sair.php só volta ao painel:

    <form method="post" action="sair.php">
      <input type="hidden" name="csrf" value="<?= token_csrf() ?>">
      <button>Sair</button>
    </form>
    <?php
    require __DIR__ . '/comum.php';
    
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        redirecionar('painel.php');
    }
    conferir_csrf();
    
    $_SESSION = [];
    $p = session_get_cookie_params();
    setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
    session_destroy();
    
    redirecionar('login.php?saiu=1');
    

    No laboratório: o token aparece no formulário; um POST sem ele recebe 403; com ele, tudo funciona. (Para o curl enviar o token, guardamos o valor num arquivo com sed; no navegador, o campo escondido faz isso sozinho.)

    $ curl -s -c jar.txt -b jar.txt localhost:8000/login.php | grep csrf
      <input type="hidden" name="csrf" value="f4f9c3453302e832d34788cc19a58fa8cddc8103ae32bf636468d61bd8d6763a">
    $ curl -s -c jar.txt -b jar.txt localhost:8000/login.php | sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p' > token.txt
    $ curl -si -c jar.txt -b jar.txt -d "csrf=$(cat token.txt)&email=ana@exemplo.com&senha=cafe-com-pao" localhost:8000/login.php | grep -E 'HTTP|Location|Set-Cookie'
    HTTP/1.1 303 See Other
    Set-Cookie: PHPSESSID=b3f89f2657ae48bfaf42ef771576d5c7; path=/; HttpOnly; SameSite=Lax
    Location: painel.php
    $ curl -si -c jar.txt -b jar.txt -d "email=ana@exemplo.com" localhost:8000/sair.php
    HTTP/1.1 403 Forbidden                       « POST sem token: recusado
    Host: localhost:8000
    Date: Fri, 16 Oct 2026 23:57:06 GMT
    Connection: close
    X-Powered-By: PHP/8.5.8
    Expires: Thu, 19 Nov 1981 08:52:00 GMT
    Cache-Control: no-store, no-cache, must-revalidate
    Pragma: no-cache
    Content-type: text/html; charset=UTF-8
    
    Formulário expirado ou inválido. Volte, recarregue a página e tente de novo.
    $ curl -s -c jar.txt -b jar.txt localhost:8000/painel.php | sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p' > token.txt
    $ curl -si -c jar.txt -b jar.txt -d "csrf=$(cat token.txt)" localhost:8000/sair.php | grep -E 'HTTP|Location|Set-Cookie'
    HTTP/1.1 303 See Other
    Set-Cookie: PHPSESSID=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/; HttpOnly
    Location: login.php?saiu=1

    As linhas Expires: Thu, 19 Nov 1981… e Cache-Control: no-store… são colocadas pelo próprio session_start(): páginas com sessão não devem ficar guardadas no cache. Um site atacante não consegue ler o token da sua página (o navegador não deixa um site ler o conteúdo de outro), então não tem como montar um POST válido. Mais sobre o ataque em CSRF e Segurança de Formulários.

    Caixa livre: esta é a versão final. A caixa começa com um POST sem token (como faria um site atacante): veja o 403. Depois abra o login.php pela aba Resultado (rode de novo sem o envio inicial, ou clique em voltar), entre com ana@exemplo.com / cafe-com-pao pelo formulário e clique em Sair: com o token no campo escondido, tudo funciona. Veja o token na aba Código-fonte HTML.

    <?php
    require __DIR__ . '/comum.php';
    
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $erro = '';
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        conferir_csrf();
    
        $busca = $pdo->prepare('SELECT id, nome, senha_hash FROM usuarios WHERE email = ?');
        $busca->execute([$email]);
        $usuario = $busca->fetch();
    
        if ($usuario && password_verify($senha, $usuario['senha_hash'])) {
            session_regenerate_id(true);
            $_SESSION['usuario'] = ['id' => $usuario['id'], 'nome' => $usuario['nome']];
            redirecionar('painel.php');
        }
        $erro = 'E-mail ou senha incorretos.';
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Entrar</title></head>
    <body>
    <h1>Entrar</h1>
    <?php if (isset($_GET['cadastrado'])): ?>
      <p class="sucesso">Conta criada! Agora é só entrar.</p>
    <?php endif; ?>
    <?php if (isset($_GET['saiu'])): ?>
      <p class="sucesso">Você saiu. Até logo!</p>
    <?php endif; ?>
    <?php if ($erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endif; ?>
    <form method="post" action="login.php">
      <input type="hidden" name="csrf" value="<?= token_csrf() ?>">
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha<br><input type="password" name="senha"></label></p>
      <p><button>Entrar</button></p>
    </form>
    <p>Não tem conta? <a href="cadastro.php">Crie uma</a></p>
    </body>
    </html>
    
    <?php
    require __DIR__ . '/comum.php';
    
    if (!isset($_SESSION['usuario'])) {
        redirecionar('login.php');
    }
    $usuario = $_SESSION['usuario'];
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Painel</title></head>
    <body>
    <h1>Olá, <?= e($usuario['nome']) ?>!</h1>
    <p>Esta é a área restrita: só aparece para quem entrou.</p>
    <form method="post" action="sair.php">
      <input type="hidden" name="csrf" value="<?= token_csrf() ?>">
      <button>Sair</button>
    </form>
    </body>
    </html>
    
    <?php
    require __DIR__ . '/comum.php';
    
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        redirecionar('painel.php');
    }
    conferir_csrf();
    
    $_SESSION = [];
    $p = session_get_cookie_params();
    setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
    session_destroy();
    
    redirecionar('login.php?saiu=1');
    
    <?php
    require __DIR__ . '/comum.php';
    
    $nome  = trim($_POST['nome'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $senha = $_POST['senha'] ?? '';
    $erros = [];
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        conferir_csrf();
    
        if (mb_strlen($nome) < 2) {
            $erros[] = 'Escreva o seu nome.';
        }
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            $erros[] = 'Esse e-mail não parece válido.';
        }
        if (mb_strlen($senha) < 8) {
            $erros[] = 'A senha precisa ter pelo menos 8 caracteres.';
        }
    
        if (!$erros) {
            $busca = $pdo->prepare('SELECT id FROM usuarios WHERE email = ?');
            $busca->execute([$email]);
            if ($busca->fetch()) {
                $erros[] = 'Já existe uma conta com esse e-mail.';
            }
        }
    
        if (!$erros) {
            $novo = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
            $novo->execute([$nome, $email, password_hash($senha, PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
            redirecionar('login.php?cadastrado=1');
        }
    }
    ?>
    <!DOCTYPE html>
    <html lang="pt-BR">
    <head><meta charset="utf-8"><title>Criar conta</title></head>
    <body>
    <h1>Criar conta</h1>
    <?php foreach ($erros as $erro): ?>
      <p class="erro"><?= e($erro) ?></p>
    <?php endforeach; ?>
    <form method="post" action="cadastro.php">
      <input type="hidden" name="csrf" value="<?= token_csrf() ?>">
      <p><label>Nome<br><input name="nome" value="<?= e($nome) ?>"></label></p>
      <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
      <p><label>Senha (mínimo 8 caracteres)<br><input type="password" name="senha"></label></p>
      <p><button>Criar conta</button></p>
    </form>
    <p>Já tem conta? <a href="login.php">Entrar</a></p>
    </body>
    </html>
    
    <?php
    // Incluído no topo de todas as páginas
    date_default_timezone_set('America/Sao_Paulo');
    session_start([
        'cookie_httponly' => true,
        'cookie_samesite' => 'Lax',
    ]);
    require __DIR__ . '/banco.php';
    
    function e(string $texto): string
    {
        return htmlspecialchars($texto);
    }
    
    function redirecionar(string $endereco): never
    {
        header('Location: ' . $endereco, true, 303);
        exit;
    }
    
    function token_csrf(): string
    {
        if (empty($_SESSION['csrf'])) {
            $_SESSION['csrf'] = bin2hex(random_bytes(32));
        }
        return $_SESSION['csrf'];
    }
    
    function conferir_csrf(): void
    {
        $enviado = $_POST['csrf'] ?? '';
        if (!is_string($enviado) || !hash_equals(token_csrf(), $enviado)) {
            http_response_code(403);
            exit('Formulário expirado ou inválido. Volte, recarregue a página e tente de novo.');
        }
    }
    
    <?php
    // Abre o banco SQLite (o arquivo é criado na primeira vez)
    $pdo = new PDO('sqlite:' . __DIR__ . '/dados.sqlite');
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    
    $pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
        nome       TEXT NOT NULL,
        email      TEXT NOT NULL UNIQUE,
        senha_hash TEXT NOT NULL,
        criado_em  TEXT NOT NULL
    )');
    
    // Só para testar: se não houver ninguém, cria a usuária de teste.
    // Apague este bloco num site de verdade.
    if ($pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn() == 0) {
        $teste = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
        $teste->execute(['Ana', 'ana@exemplo.com', password_hash('cafe-com-pao', PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
    }
    

Quando dá errado

Warning: session_start(): Session cannot be started after headers have already been sent

Uma página com HTML antes do require do comum.php:

$ curl -s localhost:8000/t-sessao.php
<p>Bem-vinda!</p>
<br />
<b>Warning</b>:  session_start(): Session cannot be started after headers have already been sent (sent from /home/ana/login/t-sessao.php on line 1) in <b>/home/ana/login/comum.php</b> on line <b>4</b><br />
ok

O que significa: a sessão usa um cookie, e cookies são cabeçalhos; depois que a página começou a sair, não dá mais. O resultado é pior que o aviso: $_SESSION fica vazio e ninguém consegue ficar logado. Como consertar: o require __DIR__ . '/comum.php'; deve ser a primeira linha de toda página, sem nada (nem espaço) antes do <?php.

Fatal error: Cannot redeclare function e()
$ curl -s localhost:8000/t-dupla.php
<br />
<b>Fatal error</b>:  Cannot redeclare function e() (previously declared in /home/ana/login/comum.php:10) in <b>/home/ana/login/comum.php</b> on line <b>10</b><br />

O comum.php foi incluído duas vezes (por exemplo, uma página que inclui o comum.php e também outro arquivo que o inclui de novo), e uma função não pode ser criada duas vezes. Como consertar: use require_once, que ignora a segunda inclusão (veja include e require).

O login nunca funciona, mesmo com a senha certa

Causas comuns: comparar a senha com o hash usando == ou gerar um hash novo e comparar (password_hash($senha) === $hash), que é sempre falso por causa do sal; ou a coluna do banco curta demais e o hash cortado. Use sempre password_verify($senha, $hash) e uma coluna TEXT (ou VARCHAR(255) no MySQL).

Logado numa aba, deslogado em outra (ou o login "não pega")

O cookie da sessão não está voltando. No curl, faltou o -b jar.txt. No navegador, confira se você usa sempre o mesmo endereço (localhost e 127.0.0.1 são "sites" diferentes para os cookies) e se nenhuma página chama session_start() depois de imprimir algo.


O código completo

A versão final, com as seis etapas.

<?php
// Abre o banco SQLite (o arquivo é criado na primeira vez)
$pdo = new PDO('sqlite:' . __DIR__ . '/dados.sqlite');
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

$pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    nome       TEXT NOT NULL,
    email      TEXT NOT NULL UNIQUE,
    senha_hash TEXT NOT NULL,
    criado_em  TEXT NOT NULL
)');

// Só para testar: se não houver ninguém, cria a usuária de teste.
// Apague este bloco num site de verdade.
if ($pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn() == 0) {
    $teste = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
    $teste->execute(['Ana', 'ana@exemplo.com', password_hash('cafe-com-pao', PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
}
<?php
// Incluído no topo de todas as páginas
date_default_timezone_set('America/Sao_Paulo');
session_start([
    'cookie_httponly' => true,
    'cookie_samesite' => 'Lax',
]);
require __DIR__ . '/banco.php';

function e(string $texto): string
{
    return htmlspecialchars($texto);
}

function redirecionar(string $endereco): never
{
    header('Location: ' . $endereco, true, 303);
    exit;
}

function token_csrf(): string
{
    if (empty($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf'];
}

function conferir_csrf(): void
{
    $enviado = $_POST['csrf'] ?? '';
    if (!is_string($enviado) || !hash_equals(token_csrf(), $enviado)) {
        http_response_code(403);
        exit('Formulário expirado ou inválido. Volte, recarregue a página e tente de novo.');
    }
}
<?php
require __DIR__ . '/comum.php';

$nome  = trim($_POST['nome'] ?? '');
$email = trim($_POST['email'] ?? '');
$senha = $_POST['senha'] ?? '';
$erros = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    conferir_csrf();

    if (mb_strlen($nome) < 2) {
        $erros[] = 'Escreva o seu nome.';
    }
    if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
        $erros[] = 'Esse e-mail não parece válido.';
    }
    if (mb_strlen($senha) < 8) {
        $erros[] = 'A senha precisa ter pelo menos 8 caracteres.';
    }

    if (!$erros) {
        $busca = $pdo->prepare('SELECT id FROM usuarios WHERE email = ?');
        $busca->execute([$email]);
        if ($busca->fetch()) {
            $erros[] = 'Já existe uma conta com esse e-mail.';
        }
    }

    if (!$erros) {
        $novo = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
        $novo->execute([$nome, $email, password_hash($senha, PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
        redirecionar('login.php?cadastrado=1');
    }
}
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Criar conta</title></head>
<body>
<h1>Criar conta</h1>
<?php foreach ($erros as $erro): ?>
  <p class="erro"><?= e($erro) ?></p>
<?php endforeach; ?>
<form method="post" action="cadastro.php">
  <input type="hidden" name="csrf" value="<?= token_csrf() ?>">
  <p><label>Nome<br><input name="nome" value="<?= e($nome) ?>"></label></p>
  <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
  <p><label>Senha (mínimo 8 caracteres)<br><input type="password" name="senha"></label></p>
  <p><button>Criar conta</button></p>
</form>
<p>Já tem conta? <a href="login.php">Entrar</a></p>
</body>
</html>
<?php
require __DIR__ . '/comum.php';

$email = trim($_POST['email'] ?? '');
$senha = $_POST['senha'] ?? '';
$erro = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    conferir_csrf();

    $busca = $pdo->prepare('SELECT id, nome, senha_hash FROM usuarios WHERE email = ?');
    $busca->execute([$email]);
    $usuario = $busca->fetch();

    if ($usuario && password_verify($senha, $usuario['senha_hash'])) {
        session_regenerate_id(true);
        $_SESSION['usuario'] = ['id' => $usuario['id'], 'nome' => $usuario['nome']];
        redirecionar('painel.php');
    }
    $erro = 'E-mail ou senha incorretos.';
}
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Entrar</title></head>
<body>
<h1>Entrar</h1>
<?php if (isset($_GET['cadastrado'])): ?>
  <p class="sucesso">Conta criada! Agora é só entrar.</p>
<?php endif; ?>
<?php if (isset($_GET['saiu'])): ?>
  <p class="sucesso">Você saiu. Até logo!</p>
<?php endif; ?>
<?php if ($erro): ?>
  <p class="erro"><?= e($erro) ?></p>
<?php endif; ?>
<form method="post" action="login.php">
  <input type="hidden" name="csrf" value="<?= token_csrf() ?>">
  <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
  <p><label>Senha<br><input type="password" name="senha"></label></p>
  <p><button>Entrar</button></p>
</form>
<p>Não tem conta? <a href="cadastro.php">Crie uma</a></p>
</body>
</html>
<?php
require __DIR__ . '/comum.php';

if (!isset($_SESSION['usuario'])) {
    redirecionar('login.php');
}
$usuario = $_SESSION['usuario'];
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Painel</title></head>
<body>
<h1>Olá, <?= e($usuario['nome']) ?>!</h1>
<p>Esta é a área restrita: só aparece para quem entrou.</p>
<form method="post" action="sair.php">
  <input type="hidden" name="csrf" value="<?= token_csrf() ?>">
  <button>Sair</button>
</form>
</body>
</html>
<?php
require __DIR__ . '/comum.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    redirecionar('painel.php');
}
conferir_csrf();

$_SESSION = [];
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
session_destroy();

redirecionar('login.php?saiu=1');
<?php
// A página inicial só manda para o painel (que manda para o login, se preciso)
header('Location: painel.php', true, 303);
exit;

Desafios extras

  1. Quem já entrou não vê o login: no topo do login.php e do cadastro.php, se já houver usuário na sessão, redirecione para o painel.
  2. Limite de tentativas: conte as tentativas erradas na sessão; depois de 5, recuse por 1 minuto (guarde a hora com time()).
  3. Trocar a senha: uma página senha.php (protegida, com token) que pede a senha atual e a nova duas vezes, confere com password_verify e grava o novo hash com UPDATE … WHERE id = ?. Chame session_regenerate_id(true) depois de trocar.
  4. Hash sempre atual: no login, depois do password_verify, use password_needs_rehash($hash, PASSWORD_DEFAULT) para regravar o hash se o PHP passar a usar um algoritmo mais forte (veja Senhas).
  5. Proteger as mensagens: leve o mensagens.php do projeto de contato para cá e tranque-o com a área restrita.

Missão final: crie a página Minha conta (aba conta.php): protegida como o painel, ela busca no banco o nome, o e-mail e a data de criação da conta pelo id guardado na sessão (SELECT nome, email, criado_em FROM usuarios WHERE id = ?, com prepared statement) e mostra Conta criada em …. No painel, acrescente o link <a href="conta.php">Minha conta</a>. A caixa abre o conta.php sem login: com a proteção pronta, você deve cair na página Entrar. Depois entre com ana@exemplo.com / cafe-com-pao e clique em Minha conta para ver os seus dados.

<?php
require __DIR__ . '/comum.php';

$email = trim($_POST['email'] ?? '');
$senha = $_POST['senha'] ?? '';
$erro = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    conferir_csrf();

    $busca = $pdo->prepare('SELECT id, nome, senha_hash FROM usuarios WHERE email = ?');
    $busca->execute([$email]);
    $usuario = $busca->fetch();

    if ($usuario && password_verify($senha, $usuario['senha_hash'])) {
        session_regenerate_id(true);
        $_SESSION['usuario'] = ['id' => $usuario['id'], 'nome' => $usuario['nome']];
        redirecionar('painel.php');
    }
    $erro = 'E-mail ou senha incorretos.';
}
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Entrar</title></head>
<body>
<h1>Entrar</h1>
<?php if (isset($_GET['cadastrado'])): ?>
  <p class="sucesso">Conta criada! Agora é só entrar.</p>
<?php endif; ?>
<?php if (isset($_GET['saiu'])): ?>
  <p class="sucesso">Você saiu. Até logo!</p>
<?php endif; ?>
<?php if ($erro): ?>
  <p class="erro"><?= e($erro) ?></p>
<?php endif; ?>
<form method="post" action="login.php">
  <input type="hidden" name="csrf" value="<?= token_csrf() ?>">
  <p><label>E-mail<br><input name="email" value="<?= e($email) ?>"></label></p>
  <p><label>Senha<br><input type="password" name="senha"></label></p>
  <p><button>Entrar</button></p>
</form>
<p>Não tem conta? <a href="cadastro.php">Crie uma</a></p>
</body>
</html>
<?php
require __DIR__ . '/comum.php';

// Missão: proteja esta página como o painel.php e mostre
// o nome, o e-mail e a data de criação da conta (busque no banco pelo id).
<?php
require __DIR__ . '/comum.php';

if (!isset($_SESSION['usuario'])) {
    redirecionar('login.php');
}
$usuario = $_SESSION['usuario'];
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Painel</title></head>
<body>
<h1>Olá, <?= e($usuario['nome']) ?>!</h1>
<p>Esta é a área restrita: só aparece para quem entrou.</p>
<form method="post" action="sair.php">
  <input type="hidden" name="csrf" value="<?= token_csrf() ?>">
  <button>Sair</button>
</form>
</body>
</html>
<?php
require __DIR__ . '/comum.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    redirecionar('painel.php');
}
conferir_csrf();

$_SESSION = [];
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
session_destroy();

redirecionar('login.php?saiu=1');
<?php
// Incluído no topo de todas as páginas
date_default_timezone_set('America/Sao_Paulo');
session_start([
    'cookie_httponly' => true,
    'cookie_samesite' => 'Lax',
]);
require __DIR__ . '/banco.php';

function e(string $texto): string
{
    return htmlspecialchars($texto);
}

function redirecionar(string $endereco): never
{
    header('Location: ' . $endereco, true, 303);
    exit;
}

function token_csrf(): string
{
    if (empty($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf'];
}

function conferir_csrf(): void
{
    $enviado = $_POST['csrf'] ?? '';
    if (!is_string($enviado) || !hash_equals(token_csrf(), $enviado)) {
        http_response_code(403);
        exit('Formulário expirado ou inválido. Volte, recarregue a página e tente de novo.');
    }
}
<?php
// Abre o banco SQLite (o arquivo é criado na primeira vez)
$pdo = new PDO('sqlite:' . __DIR__ . '/dados.sqlite');
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

$pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    nome       TEXT NOT NULL,
    email      TEXT NOT NULL UNIQUE,
    senha_hash TEXT NOT NULL,
    criado_em  TEXT NOT NULL
)');

// Só para testar: se não houver ninguém, cria a usuária de teste.
// Apague este bloco num site de verdade.
if ($pdo->query('SELECT COUNT(*) FROM usuarios')->fetchColumn() == 0) {
    $teste = $pdo->prepare('INSERT INTO usuarios (nome, email, senha_hash, criado_em) VALUES (?, ?, ?, ?)');
    $teste->execute(['Ana', 'ana@exemplo.com', password_hash('cafe-com-pao', PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
}
<?php
require __DIR__ . '/comum.php';

if (!isset($_SESSION['usuario'])) {
    redirecionar('login.php');
}

$busca = $pdo->prepare('SELECT nome, email, criado_em FROM usuarios WHERE id = ?');
$busca->execute([$_SESSION['usuario']['id']]);
$conta = $busca->fetch();
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Minha conta</title></head>
<body>
<h1>Minha conta</h1>
<p>Nome: <?= e($conta['nome']) ?></p>
<p>E-mail: <?= e($conta['email']) ?></p>
<p>Conta criada em <?= e($conta['criado_em']) ?></p>
<p><a href="painel.php">Voltar ao painel</a></p>
</body>
</html>
<?php
require __DIR__ . '/comum.php';

if (!isset($_SESSION['usuario'])) {
    redirecionar('login.php');
}
$usuario = $_SESSION['usuario'];
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="utf-8"><title>Painel</title></head>
<body>
<h1>Olá, <?= e($usuario['nome']) ?>!</h1>
<p>Esta é a área restrita: só aparece para quem entrou.</p>
<p><a href="conta.php">Minha conta</a></p>
<form method="post" action="sair.php">
  <input type="hidden" name="csrf" value="<?= token_csrf() ?>">
  <button>Sair</button>
</form>
</body>
</html>

Exercícios

Complete o cadastro e o login da senha:

$hash = ($senha, PASSWORD_DEFAULT);
if (($senha, $hash)) { … }

Complete a linha que troca o identificador da sessão no login, apagando a sessão antiga:

();

Por que o login responde "E-mail ou senha incorretos." tanto para e-mail inexistente quanto para senha errada?

O que fica guardado na coluna senha_hash?

Um site atacante tem <form action="http://localhost:8000/sair.php" method="post"> que se envia sozinho. Na versão final, o que acontece?

Resumo

  • Senha nunca vai para o banco: password_hash($senha, PASSWORD_DEFAULT) no cadastro, password_verify($senha, $hash) no login.
  • A sessão (session_start + $_SESSION) lembra quem entrou; o navegador só guarda o cookie PHPSESSID. Use cookie_httponly e cookie_samesite.
  • Área restrita: no topo da página, sem usuário na sessão, redirecione e pare (exit).
  • Logout: $_SESSION = [], apagar o cookie e session_destroy(); feito por POST com token.
  • No login, session_regenerate_id(true) contra fixação de sessão.
  • Todo formulário leva um token CSRF escondido; todo POST confere com hash_equals antes de agir.
  • Todo SQL com valores vindos de fora usa prepare + execute.

Próximo projeto: Tarefas com SQLite, um CRUD completo com PDO.