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.
- Organizar um projeto em vários arquivos com um
comum.phpincluí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_verifye 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 usamos | Aula |
|---|---|
session_start, $_SESSION, o cookie PHPSESSID | Sessões, Cookies |
password_hash, password_verify | Senhas (password_hash) |
Token CSRF, hash_equals | CSRF e Segurança de Formulários |
new PDO('sqlite:…'), prepare/execute | Conectar com PDO, Prepared Statements |
require | include 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
| Arquivo | Papel | Etapa |
|---|---|---|
banco.php | Abre o SQLite e cria a tabela usuarios | 1 |
comum.php | Incluído no topo de todas as páginas: sessão, banco e funções | 1, 2, 6 |
cadastro.php | Formulário "Criar conta" | 1 |
login.php | Formulário "Entrar" | 2, 5 |
painel.php | A área restrita | 2, 3 |
sair.php | O logout | 4, 6 |
index.php | Manda o visitante para o painel | có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
-
Etapa 1 · O banco e o cadastro
Primeiro, o lugar onde os usuários moram. O
banco.phpabre (ou cria) o arquivodados.sqlitee 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')]); }Trecho O 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 … UNIQUEimpede 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.comcom a senhacafe-com-paoquando a tabela está vazia, para você poder testar o login nas caixas. Num site de verdade, apague.O
comum.phpcomeç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 oheader('Location: …', true, 303)e oexitdo Post/Redirect/Get numa linha só. O tipo de retornoneverPHP 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 noexecute. É 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())
fetchdevolve a linha encontrada oufalse. 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_DEFAULTusa 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 lerpao-de-queijoem lugar nenhum. O hash começa com$2y$(o algoritmo bcrypt), depois12$(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 é opassword_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> -
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. Ocomum.phppassa 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 oheader(). - '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>Trecho O 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éfalsee o&&nem chama opassword_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 (oWarningda linha 4), então$usuarioénull; ler['nome']de umnulldá outroWarning; e oe(), que exige texto, recebenulle para tudo com umTypeError. 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');nologin.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>
-
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.
<?php require __DIR__ . '/comum.php'; if (!isset($_SESSION['usuario'])) { redirecionar('login.php'); } $usuario = $_SESSION['usuario']; ?>O
exitdentro doredirecionar()é 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 ocurl(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.phpa funçãoexigir_login(): array, que redireciona para o login quando não há usuário e, se houver, devolve$_SESSION['usuario']. No painel, troque oife 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']; } -
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
PHPSESSIDvazio 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 escrevedeletede uma data de 1970). Depois disso, o painel volta a mandar para o login.Sair por um link tem um problemaQualquer 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 osair.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 mostreVisitas 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> -
Etapa 5 ·
session_regenerate_id: um crachá novo a cada loginO 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.phpdesta caixa é o da etapa 4, ainda sem a proteção. Acrescente osession_regenerate_id(true);no lugar certo e confira que o login continua funcionando (o painel deve mostrarOlá, 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> -
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.'); } }Trecho O 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.phpsó 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
curlenviar o token, guardamos o valor num arquivo comsed; 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…eCache-Control: no-store…são colocadas pelo própriosession_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.phppela aba Resultado (rode de novo sem o envio inicial, ou clique em voltar), entre comana@exemplo.com/cafe-com-paopelo 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
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.
$ 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).
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).
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
- Quem já entrou não vê o login: no topo do
login.phpe docadastro.php, se já houver usuário na sessão, redirecione para o painel. - Limite de tentativas: conte as tentativas erradas na sessão; depois de 5, recuse por 1 minuto (guarde a hora com
time()). - Trocar a senha: uma página
senha.php(protegida, com token) que pede a senha atual e a nova duas vezes, confere compassword_verifye grava o novo hash comUPDATE … WHERE id = ?. Chamesession_regenerate_id(true)depois de trocar. - Hash sempre atual: no login, depois do
password_verify, usepassword_needs_rehash($hash, PASSWORD_DEFAULT)para regravar o hash se o PHP passar a usar um algoritmo mais forte (veja Senhas). - Proteger as mensagens: leve o
mensagens.phpdo 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:
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 cookiePHPSESSID. Usecookie_httponlyecookie_samesite. - Área restrita: no topo da página, sem usuário na sessão, redirecione e pare (
exit). - Logout:
$_SESSION = [], apagar o cookie esession_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_equalsantes de agir. - Todo SQL com valores vindos de fora usa
prepare+execute.
Próximo projeto: Tarefas com SQLite, um CRUD completo com PDO.