Programação Por Equipe DevBras

Capa do artigo: Login em PHP e MySQL: a senha que não pode ser lida nem por você

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

  1. Configure o cookie de sessão com secure, httponly e samesite antes de iniciar a sessão.
  2. Recuse a tentativa se aquele e-mail ou IP já errou demais nos últimos minutos.
  3. Busque o usuário pelo e-mail, com parâmetro.
  4. 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.
  5. Responda com a mesma mensagem para e-mail inexistente e senha errada.
  6. 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_hash e login usa password_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, HttpOnly e SameSite.
  • 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?

Avalie este artigo

Referência: http://www.linhadecodigo.com.br/artigo/3561/criando-um-sistema-de-cadastro-e-login-com-php-e-mysql.aspx