Limite de tentativas no login: avise a pausa em vez de dizer "senha incorreta"
Implemente um limite de tentativas de login no servidor que diz que há uma pausa e quanto falta, sem revelar se a conta existe e sem trancar a pessoa de vez.
Segunda, 9h10. A Ana errou a senha cinco vezes no painel da loja, lembrou a certa na sexta tentativa e a tela respondeu de novo "usuário ou senha incorretos". Ela pediu redefinição, criou uma senha nova, tentou e recebeu a mesma mensagem. O limite de tentativas estava funcionando. Quem mentiu foi a tela.
A resposta curta: quando o limite disparar, diga que é uma pausa e quanto tempo falta, com uma mensagem diferente de "senha não confere". Aplique o limite no servidor, contando por login digitado e por origem, libere sozinho depois de alguns minutos e use a mesma mensagem de pausa exista a conta ou não. Assim você protege contra força bruta sem fazer o usuário legítimo trocar a senha à toa.
Por que o bloqueio silencioso vira chamado de suporte
Muita implementação de limite nasce como um if antes da conferência da senha: se passou de cinco tentativas, devolve o mesmo erro de credencial e nem olha o banco. O raciocínio é "não dar pista ao atacante". O efeito colateral é que a pessoa honesta fica presa num laço: digita certo, recebe errado, conclui que esqueceu a senha.
O atacante, por outro lado, não é enganado. Um script de força bruta percebe em poucas tentativas que todas as respostas viraram iguais e muda de alvo ou de IP. A mentira só atinge quem você queria atender. O segredo que importa proteger é se a conta existe, não o fato de que existe um limite.
Há um segundo problema: limite que tranca a conta até alguém do suporte liberar vira arma. Basta alguém digitar o e-mail do diretor financeiro errado dez vezes para ele ficar sem acesso na hora do fechamento. Por isso a pausa precisa expirar sozinha.
Como desenhar o limite que avisa
O exemplo abaixo é do painel da loja da Ana: Node.js com Express e Redis, porque o contador precisa ser compartilhado entre as instâncias da aplicação. Contador em memória funciona com um processo só e falha no dia em que você sobe a segunda réplica.
- Defina duas chaves de contagem. Uma pelo login digitado (normalizado em minúsculas, sem espaços nas pontas) e outra pelo IP de origem. A primeira freia ataque concentrado numa conta; a segunda freia quem testa muitas contas de um mesmo lugar.
- Conte pelo que foi digitado, não pelo usuário encontrado. Se você só incrementa quando a conta existe, a pausa só aparece para contas reais e a mensagem de pausa vira um detector de cadastro.
- Escolha números concretos. No exemplo: 5 falhas por login em 15 minutos e 50 falhas por IP no mesmo período. O limite por IP é mais alto porque escritórios e redes móveis colocam muita gente atrás do mesmo endereço.
- Confira o limite antes da senha. Se a chave já estourou, responda com a pausa sem rodar o hash. Isso também economiza CPU, já que bcrypt e Argon2 são caros de propósito.
- Zere o contador do login no sucesso. Quem acertou não deve carregar as falhas antigas.
- Responda com status 429 e o cabeçalho
Retry-Afterem segundos, e mostre a mesma informação em texto para a pessoa.
const express = require('express');
const { createClient } = require('redis');
const redis = createClient({ url: process.env.REDIS_URL });
redis.on('error', (err) => console.error('redis', err));
redis.connect().catch((err) => {
console.error('sem Redis, sem limite: não suba o login assim', err);
process.exit(1);
});
const JANELA_S = 15 * 60;
const MAX_POR_LOGIN = 5;
const MAX_POR_IP = 50;
function chaves(req) {
const login = String(req.body.login || '').trim().toLowerCase();
return { porLogin: 'rl:login:' + login, porIp: 'rl:ip:' + req.ip };
}
async function segundosDePausa(req) {
const { porLogin, porIp } = chaves(req);
const [nLogin, nIp] = await Promise.all([redis.get(porLogin), redis.get(porIp)]);
if (Number(nLogin) >= MAX_POR_LOGIN) return redis.ttl(porLogin);
if (Number(nIp) >= MAX_POR_IP) return redis.ttl(porIp);
return 0;
}
async function registrarFalha(req) {
for (const chave of Object.values(chaves(req))) {
const n = await redis.incr(chave);
if (n === 1) await redis.expire(chave, JANELA_S);
}
}
const app = express();
app.use(express.urlencoded({ extended: false }));
app.post('/login', async (req, res) => {
const pausa = await segundosDePausa(req);
if (pausa > 0) {
const minutos = Math.ceil(pausa / 60);
res.set('Retry-After', String(pausa));
return res.status(429).render('login', {
aviso: 'Muitas tentativas seguidas. Por segurança, aguarde ' + minutos +
' minuto(s) e tente de novo. Sua senha não foi alterada.'
});
}
const usuario = await autenticar(req.body.login, req.body.senha);
if (!usuario) {
await registrarFalha(req);
return res.status(401).render('login', { aviso: 'Usuário ou senha incorretos.' });
}
await redis.del(chaves(req).porLogin);
req.session.usuarioId = usuario.id;
res.redirect('/painel');
});
A função autenticar é a sua conferência de senha com hash, como a do guia de login com senha protegida. Repare que ela é chamada só quando não há pausa, e que a mensagem de pausa não depende de a conta existir.
Conferindo o comportamento com curl
Antes de subir, simule a Ana. Seis tentativas erradas seguidas devem produzir cinco respostas 401 e uma 429:
for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/login \
-d "login=ana@loja.exemplo&senha=errada$i"
done
# saída esperada: 401 401 401 401 401 429
Repita com um login que não existe, como ninguem@loja.exemplo. A sequência de códigos e o texto da pausa precisam ser idênticos. Se forem diferentes, o limite está vazando o cadastro.
O IP que o servidor enxerga
Atrás de um proxy reverso ou balanceador, req.ip pode ser o endereço do proxy para todo mundo, e o limite por IP passa a bloquear a loja inteira de uma vez. No Express, configure app.set('trust proxy', ...) de acordo com quantos proxies você controla na frente da aplicação. Não confie em qualquer X-Forwarded-For vindo da internet, porque o cliente pode escrever o que quiser nesse cabeçalho.
O texto que a pessoa lê
A mensagem de pausa tem três partes: o que aconteceu, quanto falta e o que não aconteceu. "Muitas tentativas seguidas. Aguarde 15 minutos e tente de novo. Sua senha não foi alterada." A última frase evita exatamente o erro da Ana, que foi trocar a senha sem necessidade.
Evite "sua conta foi bloqueada", que assusta e sugere que alguém precisa liberar. Evite também o contador "restam 2 tentativas": ele ajuda o script a calibrar o ataque e, se a contagem tiver qualquer diferença entre conta real e inexistente, vira mais um sinal de cadastro. Se quiser oferecer saída, um link discreto para "esqueci a senha" serve, desde que essa rota tenha seu próprio limite, como explica o post sobre limite no esqueci a senha.
Para o caso comum, não exija confirmação por e-mail nem captcha logo na primeira pausa. Captcha pode entrar como degrau extra quando o mesmo IP passa do limite várias vezes, mas o usuário que só errou a senha cinco vezes merece apenas esperar alguns minutos.
Erros comuns
- Repetir "senha incorreta" durante a pausa: a pessoa troca a senha, continua barrada e abre chamado achando que o sistema quebrou.
- Contar só quando a conta existe: a mensagem de pausa denuncia quais e-mails estão cadastrados, o mesmo vazamento discutido em erro de login genérico.
- Limitar só no front-end, desabilitando o botão: qualquer script fala direto com a API e ignora o botão.
- Trancar a conta até intervenção manual: qualquer pessoa consegue bloquear um colega de propósito digitando o e-mail dele errado.
- Contador em memória com várias instâncias: cada réplica conta sozinha, e o limite real vira 5 vezes o número de servidores.
- Usar o IP do proxy: todos os clientes compartilham a mesma chave e uma pessoa errando a senha pausa a loja inteira.
Checklist
- A tentativa de número 6 devolve 429 com
Retry-Aftere uma mensagem que fala em pausa. - A mensagem de pausa é idêntica para login existente e inexistente.
- O contador fica num armazenamento compartilhado (Redis ou banco), não na memória do processo.
- Há limite por login digitado e outro, mais alto, por IP.
- A pausa expira sozinha, sem ação do suporte.
- O contador do login é zerado depois de um acesso bem-sucedido.
req.ipmostra o IP real do cliente atrás do proxy, e não o do balanceador.- A rota de "esqueci a senha" tem limite próprio.
Abra agora a tela de login do seu sistema e erre a senha de propósito até o limite: a mensagem que aparece distingue "senha não confere" de "espere um pouco", e diz quanto tempo falta?
Referência: https://cheatsheetseries.owasp.org/cheatsheets/Denial_of_Service_Cheat_Sheet.html