Programação Por Equipe DevBras

Capa do artigo: Login PHP copiado de tutorial antigo: como auditar e migrar sem trocar a senha de todos

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'];
  1. SQL montado com o que foi digitado. A aspa no campo muda a consulta.
  2. 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.
  3. or die(mysql_error()). O erro do banco vai para a tela, com nome de tabela e trecho da consulta.
  4. Sessão sem session_regenerate_id. O identificador anterior ao login continua valendo depois dele.
  5. header("Location") sem exit. No painel.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.
  6. Nome de usuário impresso sem escape. Um login com HTML vira código na página.
  7. Nenhum limite de tentativas. Um script testa milhares de senhas sem ninguém perceber.

Como reescrever o login sem os buracos

  1. Troque a conexão por PDO com exceções ligadas e conjunto de caracteres utf8mb4.
  2. Busque o usuário só pelo login, com parâmetro, e compare a senha com password_verify.
  3. Responda sempre a mesma mensagem, exista o usuário ou não.
  4. Regenere o identificador da sessão no login bem-sucedido.
  5. Coloque exit depois de todo redirecionamento.
  6. Configure o cookie de sessão com Secure, HttpOnly e SameSite.
<?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.

  1. Faça backup da tabela e teste a restauração.
  2. Crie a coluna nova e a marca de legado.
  3. Rode o script que embrulha cada MD5.
  4. Publique o login novo, que sabe tratar os dois formatos.
  5. 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_* por mysqli_* mantendo a concatenação: o código volta a rodar no PHP novo com a mesma injeção de SQL.
  • Usar addslashes ou mysqli_real_escape_string como 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_hash por cima.
  • O login chama session_regenerate_id(true) ao autenticar.
  • Todo header('Location') é seguido de exit.
  • O cookie de sessão sai com Secure, HttpOnly e SameSite.
  • 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?

Avalie este artigo

Referência: http://www.aldeiarpg.com/t2898-tutorial-sistema-de-cadastrologin-phpmysql