Login PHP copiado de tutorial antigo: como auditar e migrar sem trocar a senha de todos
Reconheça os sete sinais de um login de fórum antigo, reescreva com PDO, password_hash e sessão regenerada, e migre as senhas em MD5 sem obrigar ninguém a cadastrar de novo.
O sistema de agendamento de um salão roda há oito anos com um login copiado de um tópico de fórum. Funciona, ninguém reclama, e a tabela usuarios guarda 3.200 senhas em MD5. Num teste simples, digitar ' OR 1=1 # no campo de usuário abre o painel da dona. A resposta curta: não remende o tutorial. Troque a consulta por PDO com parâmetros, a senha por password_hash, regenere a sessão no login e embrulhe os MD5 antigos num hash forte hoje, sem esperar cada pessoa entrar.
Este guia é sobre o código herdado: como reconhecer o padrão, o que reescrever e como migrar a base existente. Se você está começando um login do zero, o guia de login em PHP com senha protegida é o ponto de partida.
Por que esses tutoriais envelheceram tão mal
Os tópicos de 2008 a 2013 ensinavam o que fazia a tela funcionar na primeira semana. A prioridade era "loga e mostra o nome". Segurança aparecia, quando aparecia, como addslashes ou md5, que na época pareciam suficientes. As funções mysql_* que eles usam foram removidas no PHP 7, então quem ainda tem esse código ou está num PHP sem suporte ou fez uma troca mecânica para mysqli mantendo a concatenação.
O problema não é a idade, é a cópia. O código chega ao projeto sem o contexto, e quem cola testa só o caminho feliz: usuário certo, senha certa, entra. Nenhum teste digita uma aspa.
Os sete sinais de um login de fórum antigo
Este é o formato típico, reduzido ao essencial. Se o seu se parece com isso, ele precisa ser reescrito:
<?php
session_start();
$login = $_POST['login'];
$senha = md5($_POST['senha']);
$sql = mysql_query("SELECT * FROM usuarios WHERE login = '$login' AND senha = '$senha'") or die(mysql_error());
if (mysql_num_rows($sql) > 0) {
$_SESSION['logado'] = true;
$_SESSION['login'] = $login;
header("Location: painel.php");
} else {
echo "Usuário não encontrado ou senha incorreta";
}
// painel.php
session_start();
if (!isset($_SESSION['logado'])) {
header("Location: login.php");
}
echo "Bem-vindo, " . $_SESSION['login'];
- SQL montado com o que foi digitado. A aspa no campo muda a consulta.
- Senha em MD5, SHA1 ou texto puro. MD5 é rápido de propósito, e por isso é rápido de quebrar com placa de vídeo quando a base vaza.
or die(mysql_error()). O erro do banco vai para a tela, com nome de tabela e trecho da consulta.- Sessão sem
session_regenerate_id. O identificador anterior ao login continua valendo depois dele. header("Location")semexit. Nopainel.php, o redirecionamento é só um cabeçalho: o PHP continua executando e o HTML do painel vai junto na resposta. Um cliente que ignora o redirecionamento lê tudo.- Nome de usuário impresso sem escape. Um login com HTML vira código na página.
- Nenhum limite de tentativas. Um script testa milhares de senhas sem ninguém perceber.
Como reescrever o login sem os buracos
- Troque a conexão por PDO com exceções ligadas e conjunto de caracteres
utf8mb4. - Busque o usuário só pelo login, com parâmetro, e compare a senha com
password_verify. - Responda sempre a mesma mensagem, exista o usuário ou não.
- Regenere o identificador da sessão no login bem-sucedido.
- Coloque
exitdepois de todo redirecionamento. - Configure o cookie de sessão com
Secure,HttpOnlyeSameSite.
<?php
declare(strict_types=1);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
function conectar(): PDO
{
return new PDO(
'mysql:host=' . getenv('DB_HOST') . ';dbname=' . getenv('DB_NAME') . ';charset=utf8mb4',
getenv('DB_USER'),
getenv('DB_PASS'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => false]
);
}
$login = trim((string) ($_POST['login'] ?? ''));
$senha = (string) ($_POST['senha'] ?? '');
$falha = 'Login ou senha incorretos.';
try {
$pdo = conectar();
$stmt = $pdo->prepare('SELECT id, senha_hash, legado_md5 FROM usuarios WHERE login = ?');
$stmt->execute([$login]);
$usuario = $stmt->fetch(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log('Login: falha no banco: ' . $e->getMessage());
http_response_code(503);
exit('Não foi possível entrar agora. Tente em alguns minutos.');
}
if (!$usuario) {
password_verify($senha, password_hash('senha-ficticia', PASSWORD_DEFAULT));
exit($falha);
}
$tentativa = $usuario['legado_md5'] ? md5($senha) : $senha;
if (!password_verify($tentativa, $usuario['senha_hash'])) {
exit($falha);
}
if ($usuario['legado_md5'] || password_needs_rehash($usuario['senha_hash'], PASSWORD_DEFAULT)) {
$novo = $pdo->prepare('UPDATE usuarios SET senha_hash = ?, legado_md5 = 0 WHERE id = ?');
$novo->execute([password_hash($senha, PASSWORD_DEFAULT), $usuario['id']]);
}
session_regenerate_id(true);
$_SESSION['usuario_id'] = (int) $usuario['id'];
header('Location: /painel.php', true, 303);
exit;
O password_verify com um hash fictício, quando o usuário não existe, gasta o mesmo tempo de uma verificação real. Sem ele, a resposta mais rápida denuncia quais logins existem. A mesma ideia vale para a mensagem, como explica o guia sobre erro de login genérico.
No painel.php, a guarda fica assim, sempre com exit:
<?php
session_start();
if (empty($_SESSION['usuario_id'])) {
header('Location: /login.php', true, 303);
exit;
}
E qualquer dado do usuário que volte para a tela passa por htmlspecialchars($valor, ENT_QUOTES, 'UTF-8').
Como migrar as senhas em MD5 sem esperar ninguém
A tentação é rehashear "quando a pessoa entrar de novo". Só que 40% dos usuários do salão não entram há mais de um ano, e o MD5 deles fica na tabela indefinidamente. A saída é embrulhar: aplicar password_hash sobre o MD5 que já está guardado. Assim nenhum MD5 nu sobra no banco a partir de hoje.
- Faça backup da tabela e teste a restauração.
- Crie a coluna nova e a marca de legado.
- Rode o script que embrulha cada MD5.
- Publique o login novo, que sabe tratar os dois formatos.
- Apague a coluna antiga depois de conferir.
ALTER TABLE usuarios
ADD COLUMN senha_hash VARCHAR(255) NULL,
ADD COLUMN legado_md5 TINYINT(1) NOT NULL DEFAULT 0;
<?php
// migrar-md5.php: rodar uma vez, pela linha de comando
require __DIR__ . '/conexao.php'; // a mesma função conectar() do login
$pdo = conectar();
$lista = $pdo->query('SELECT id, senha FROM usuarios WHERE senha_hash IS NULL')->fetchAll(PDO::FETCH_ASSOC);
$upd = $pdo->prepare('UPDATE usuarios SET senha_hash = ?, legado_md5 = 1 WHERE id = ?');
foreach ($lista as $linha) {
$upd->execute([password_hash($linha['senha'], PASSWORD_DEFAULT), $linha['id']]);
}
echo count($lista) . ' senhas embrulhadas' . PHP_EOL;
No login, quem tem legado_md5 = 1 é verificado com md5($senha) contra o hash forte, e no primeiro acesso ganha um hash direto da senha, com a marca zerada. Depois de conferir que todas as linhas têm senha_hash, remova a coluna senha antiga. Se a base vazar amanhã, o atacante encontra bcrypt, não MD5. A coluna tem 255 caracteres porque o PHP pode mudar o algoritmo padrão no futuro, e o hash novo pode ser mais longo.
Erros comuns
- Trocar
mysql_*pormysqli_*mantendo a concatenação: o código volta a rodar no PHP novo com a mesma injeção de SQL. - Usar
addslashesoumysqli_real_escape_stringcomo proteção principal: funciona até alguém esquecer um campo ou errar o conjunto de caracteres; parâmetro não depende de lembrança. - Esperar o próximo login para migrar o MD5: as contas paradas continuam expostas por anos.
- Redirecionar sem
exit: a página protegida é gerada e enviada mesmo para quem não está logado. - Manter a sessão antiga depois do login: quem conseguiu fixar o identificador antes herda a sessão autenticada.
- Mensagens diferentes para usuário inexistente e senha errada: a tela vira ferramenta para descobrir quem tem conta.
Checklist
- Nenhuma consulta do login concatena dados do formulário.
- Não existe mais senha em MD5, SHA1 ou texto puro sem um
password_hashpor cima. - O login chama
session_regenerate_id(true)ao autenticar. - Todo
header('Location')é seguido deexit. - O cookie de sessão sai com
Secure,HttpOnlyeSameSite. - Erros do banco vão para o log, não para a tela.
- Usuário inexistente e senha errada recebem a mesma resposta.
- Há limite de tentativas por login e por IP.
Digite ' OR 1=1 # no campo de usuário do seu login e qualquer coisa na senha: a resposta é "login ou senha incorretos" ou o painel?
Referência: http://www.aldeiarpg.com/t2898-tutorial-sistema-de-cadastrologin-phpmysql