Login em PHP e MySQL: a senha que não pode ser lida nem por você
Monte cadastro e login com password_hash, password_verify, consulta com parâmetro, sessão regenerada e limite de tentativas, sem nunca guardar a senha legível.
O tutorial antigo grava a senha na coluna senha e faz WHERE email = '$email' AND senha = '$senha'. Funciona no primeiro login. No dia do vazamento, funciona também para quem baixou a tabela, e para quem digitou ' OR '1'='1 no campo de e-mail. A resposta curta: no cadastro, grave password_hash($senha, PASSWORD_DEFAULT). No login, busque a pessoa pelo e-mail com consulta parametrizada e confira com password_verify. Depois do sim, regenere o id da sessão. Se você consegue ler alguma senha na sua tabela, o sistema ainda está no tutorial velho.
Por que hash de senha não é MD5
Hash é uma função de mão única: da senha sai um resumo, e do resumo não se volta para a senha. O problema do MD5 e do SHA-1 para senha não é serem reversíveis, é serem rápidos. Uma placa de vídeo comum testa bilhões de candidatos por segundo contra uma tabela vazada. Senha curta ou comum cai em minutos.
Funções feitas para senha, como bcrypt e Argon2, são lentas de propósito e usam sal: um valor aleatório diferente para cada usuário, guardado junto do hash. Com o sal, duas pessoas com a mesma senha têm hashes diferentes, e tabelas pré-calculadas deixam de servir. O PHP entrega isso pronto. password_hash gera o sal, aplica o algoritmo e devolve uma string que já contém algoritmo, custo e sal. password_verify lê essa string e confere. Você não precisa, e não deve, montar sal na mão.
PASSWORD_DEFAULT hoje usa bcrypt e pode mudar para algo mais forte em versões futuras do PHP. Por isso a coluna deve ter folga: VARCHAR(255). Um detalhe do bcrypt que vale saber: ele só considera os primeiros 72 bytes da senha. Para quase todo mundo isso não importa, mas não anuncie "sem limite de tamanho" achando que o resto conta.
Como fazer o cadastro
CREATE TABLE usuarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(254) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
<?php
declare(strict_types=1);
$pdo = require __DIR__ . '/conexao.php'; // devolve um PDO com ERRMODE_EXCEPTION
$email = trim((string) ($_POST['email'] ?? ''));
$senha = (string) ($_POST['senha'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($senha) < 10) {
http_response_code(422);
exit('Informe um e-mail válido e uma senha com pelo menos 10 caracteres.');
}
$stmt = $pdo->prepare('INSERT INTO usuarios (email, senha_hash) VALUES (?, ?)');
$stmt->execute([$email, password_hash($senha, PASSWORD_DEFAULT)]);
O mínimo de 10 caracteres é um exemplo. Comprimento ajuda mais do que exigir símbolo e número. A senha em si nunca vai para log, e-mail de boas-vindas ou mensagem de erro. Revise também o que o seu log grava das requisições, porque um print_r($_POST) esquecido desfaz todo o cuidado.
Como fazer o login passo a passo
- Configure o cookie de sessão com
secure,httponlyesamesiteantes de iniciar a sessão. - Recuse a tentativa se aquele e-mail ou IP já errou demais nos últimos minutos.
- Busque o usuário pelo e-mail, com parâmetro.
- Confira a senha com
password_verify, mesmo quando o usuário não existe, para o tempo de resposta não denunciar quem tem conta. - Responda com a mesma mensagem para e-mail inexistente e senha errada.
- No sucesso, atualize o hash se o algoritmo ou o custo mudaram, regenere o id da sessão e grave só o id do usuário nela.
<?php
declare(strict_types=1);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$pdo = require __DIR__ . '/conexao.php';
function excedeuTentativas(PDO $pdo, string $email, string $ip): bool
{
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM tentativas_login
WHERE (email = ? OR ip = ?) AND criado_em > NOW() - INTERVAL 15 MINUTE'
);
$stmt->execute([$email, $ip]);
return (int) $stmt->fetchColumn() >= 10;
}
function registrarFalha(PDO $pdo, string $email, string $ip): void
{
$pdo->prepare('INSERT INTO tentativas_login (email, ip, criado_em) VALUES (?, ?, NOW())')
->execute([$email, $ip]);
}
$email = trim((string) ($_POST['email'] ?? ''));
$senha = (string) ($_POST['senha'] ?? '');
$ip = $_SERVER['REMOTE_ADDR'];
if (excedeuTentativas($pdo, $email, $ip)) {
http_response_code(429);
exit('Muitas tentativas. Espere alguns minutos.');
}
$stmt = $pdo->prepare('SELECT id, senha_hash FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
$usuario = $stmt->fetch(PDO::FETCH_ASSOC);
$hash = $usuario['senha_hash'] ?? password_hash('usuario-inexistente', PASSWORD_DEFAULT);
$confere = password_verify($senha, $hash);
if ($usuario === false || !$confere) {
registrarFalha($pdo, $email, $ip);
http_response_code(401);
exit('E-mail ou senha incorretos.');
}
if (password_needs_rehash($usuario['senha_hash'], PASSWORD_DEFAULT)) {
$pdo->prepare('UPDATE usuarios SET senha_hash = ? WHERE id = ?')
->execute([password_hash($senha, PASSWORD_DEFAULT), $usuario['id']]);
}
session_regenerate_id(true);
$_SESSION['usuario_id'] = (int) $usuario['id'];
header('Location: /painel', true, 303);
exit;
O REMOTE_ADDR só é o IP real se não houver proxy na frente. Atrás de balanceador, use o cabeçalho que o seu proxy confiável define, e nunca aceite esse cabeçalho vindo direto da internet.
O session_regenerate_id(true) troca o identificador da sessão e apaga a antiga. Sem isso, alguém que plantou um id de sessão no navegador da vítima antes do login herda a conta logada. O cookie com httponly impede que JavaScript leia o identificador, o que limita o estrago de um XSS. Os detalhes de cada atributo estão no guia de cookie HttpOnly.
Limite de tentativas sem trancar todo mundo
Contar por IP e por e-mail tem efeito colateral. Um escritório inteiro sai pelo mesmo IP, e qualquer pessoa pode errar a senha de propósito para travar a conta de outra. Por isso o exemplo bloqueia por 15 minutos em vez de travar a conta até alguém liberar. Em sistemas maiores, atraso crescente e um desafio depois de algumas falhas costumam incomodar menos o usuário legítimo. Limpe a tabela de tentativas antigas com um job.
Migrando senhas antigas em MD5
Se a tabela já tem MD5, não peça a todo mundo para trocar a senha no mesmo dia. No login, se o valor guardado tem cara de MD5 (32 caracteres hexadecimais), compare com hash_equals($guardado, md5($senha)). Se bater, grave na hora o password_hash da senha digitada. Quem não entrar em alguns meses recebe um fluxo de redefinição, e o MD5 restante é apagado.
Logout e troca de senha
Sair é destruir a sessão no servidor, não só apagar o cookie no navegador:
$_SESSION = [];
session_destroy();
setcookie(session_name(), '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
Quando a pessoa troca a senha, as outras sessões dela deveriam cair. Um jeito simples é guardar uma versão da senha no usuário e na sessão. Se não baterem, a sessão está velha e volta para o login. O "lembrar de mim" é um token aleatório próprio, com prazo, guardado como hash no banco. Nunca é a senha salva num cookie.
Erros comuns
- Guardar a senha em texto ou em MD5. Um vazamento entrega as senhas, e muita gente repete a mesma senha no e-mail e no banco.
- Comparar a senha no SQL.
WHERE senha = ?só funciona se a senha estiver legível ou se o hash for sem sal, e os dois são o problema. - Montar a consulta com concatenação. O campo de e-mail vira porta para injeção de SQL e login sem senha.
- Mensagens diferentes para "e-mail não existe" e "senha errada". A tela de login vira consulta de quem é cliente.
- Não regenerar a sessão no login. Abre espaço para fixação de sessão.
- Coluna
VARCHAR(60)para o hash. Funciona com bcrypt e corta o hash quando o padrão mudar.
Checklist
- Nenhuma senha legível na tabela de usuários, nem em logs.
- Cadastro usa
password_hashe login usapassword_verify. - Toda consulta do login usa parâmetro.
- A mesma mensagem aparece para e-mail inexistente e senha errada.
session_regenerate_id(true)roda logo depois do login bem-sucedido.- O cookie de sessão sai com
Secure,HttpOnlyeSameSite. - Há limite de tentativas por e-mail e por IP.
- Logout destrói a sessão no servidor.
Abra agora a tabela de usuários e olhe a coluna de senha de uma conta qualquer. Ela começa com algo como $2y$ ou $argon2id$, ou você consegue ler, ou reconhecer, o que está escrito ali?
Referência: http://www.linhadecodigo.com.br/artigo/3561/criando-um-sistema-de-cadastro-e-login-com-php-e-mysql.aspx