Skip to content

fix(seguranca): limitar tentativas de login (forca bruta) - #23

Merged
janeiaraujo merged 1 commit into
mainfrom
fix/login-rate-limit
Aug 3, 2026
Merged

fix(seguranca): limitar tentativas de login (forca bruta)#23
janeiaraujo merged 1 commit into
mainfrom
fix/login-rate-limit

Conversation

@janeiaraujo

Copy link
Copy Markdown
Owner

O problema

A única proteção era o rate limit global de 100 req/min por IP. Para senha isso é permissivo demais: permitiria cerca de 6.000 tentativas por hora vindas de uma única origem, sem nenhuma consequência para a conta alvo. Num repositório público, é o tipo de coisa que alguém aponta em cinco minutos.

Duas camadas, para ataques diferentes

1. Limite por IP nas rotas de auth (config.rateLimit por rota):

Rota Limite Por quê
/login 10 / 15 min Barra volume de tentativas de senha
/magic-link 5 / 15 min Dispara e-mail — evita usar o sistema como ferramenta de spam
/magic-verify 10 / 15 min Barra força bruta no próprio token
/register 5 / hora Evita criação massiva de organizações

2. Bloqueio temporário por conta: 5 senhas erradas bloqueiam por 15 minutos. Isso cobre o que o limite por IP não cobre — tentativa distribuída (vários IPs) contra um usuário específico. Login válido zera o contador.

O bloqueio é por tempo, não permanente, de propósito: bloqueio permanente viraria um vetor de negação de serviço, bastando errar a senha de alguém de propósito para travar a conta da pessoa.

Bug encontrado no caminho

O catch da rota de login devolvia 401 para tudo, o que mascararia o 429 do bloqueio — a interface diria "credenciais inválidas" para uma conta que na verdade está temporariamente travada, deixando a pessoa tentando de novo sem entender. Agora respeita o status do erro.

Validação

Subi o Fastify isolado (sem Mongo) reproduzindo o mesmo registro global do server.js e confirmei o comportamento real do @fastify/rate-limit 10.x:

/login (limite 10):        10 x 200, 2 x 429
/sem-limite-proprio (100): 12 x 200

Ou seja: o limite por rota entra em vigor e é isolado do global.

Test plan

  • Limite por rota verificado empiricamente
  • node --check nos arquivos alterados
  • Com o app rodando: errar a senha 5 vezes e confirmar a mensagem de bloqueio com os minutos restantes
  • Confirmar que um login correto depois do desbloqueio zera o contador

O unico limite era o global de 100 req/min por IP - permissivo demais
para senha: ~6.000 tentativas por hora de uma unica origem, sem
nenhuma consequencia para a conta alvo.

Duas camadas, que cobrem ataques diferentes:

1. Limite por IP nas rotas de auth (config.rateLimit por rota):
   login 10/15min, magic-link 5/15min (tambem evita usar o sistema
   para disparar e-mail), magic-verify 10/15min, register 5/hora.
   Verificado subindo o Fastify isolado: a 11a tentativa em /login
   volta 429 enquanto rotas sem limite proprio seguem no global.

2. Bloqueio temporario por conta: 5 senhas erradas bloqueiam por 15
   minutos, o que barra tentativa distribuida por varios IPs contra um
   usuario. O bloqueio e por tempo, nao permanente - permanente viraria
   um vetor de negacao de servico, bastando errar a senha de alguem de
   proposito. Login valido zera o contador.

O catch da rota de login propagava 401 para tudo; agora respeita o 429
do bloqueio, senao a UI diria "credenciais invalidas" para uma conta
que na verdade esta travada.
Copilot AI review requested due to automatic review settings August 3, 2026 18:08
@janeiaraujo
janeiaraujo merged commit c362ddf into main Aug 3, 2026
4 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Este PR fortalece o módulo de autenticação contra força bruta ao adicionar rate limit específico por rota e um bloqueio temporário por conta após repetidas falhas de senha, além de ajustar o tratamento de erros para não mascarar respostas 429.

Changes:

  • Adiciona rate limit por IP, por rota, em endpoints de autenticação (/login, /magic-link, /magic-verify, /register).
  • Implementa bloqueio temporário por conta após 5 tentativas falhas, com reset após login válido.
  • Atualiza documentação (CONTRIBUTING e .env.example) para registrar as duas camadas de proteção e esclarecer o rate limit global vs. por rota.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
CONTRIBUTING.md Documenta as duas camadas de proteção (rate limit por IP + lock por conta) para futuras mudanças.
backend/src/modules/auth/auth.service.js Implementa lock por conta e reset do contador/lock no login válido.
backend/src/modules/auth/auth.routes.js Aplica rate limit por rota e ajusta resposta de erro do /login para respeitar statusCode.
backend/.env.example Esclarece que o rate limit global não substitui os limites mais restritos de autenticação definidos em código.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 138 to 144
if (!user) {
throw new Error('Invalid credentials');
}

if (!user.password) {
throw new Error('Password not set. Please use magic link.');
}
Comment on lines 142 to 147
} catch (error) {
fastify.log.error(error);
return reply.status(401).send({
error: error.message || 'Login failed'
// Conta bloqueada por excesso de tentativas vem com 429; o resto e 401.
return reply.status(error.statusCode || 401).send({
error: error.message || 'Login failed'
});
Comment on lines +184 to +194
async function registerFailedLogin(db, user) {
const attempts = (user.failed_login_attempts || 0) + 1;
const updates = { failed_login_attempts: attempts };

if (attempts >= MAX_FAILED_LOGIN_ATTEMPTS) {
updates.locked_until = new Date(Date.now() + LOGIN_LOCK_MINUTES * 60 * 1000);
updates.failed_login_attempts = 0;
}

await db.collection('users').updateOne({ _id: user._id }, { $set: updates });
}
Comment on lines +146 to +154
// Conta bloqueada: nao gasta bcrypt e nao permite continuar tentando
if (user.locked_until && user.locked_until > new Date()) {
const minutesLeft = Math.ceil((user.locked_until - new Date()) / 60000);
const error = new Error(
`Muitas tentativas de login. Tente novamente em ${minutesLeft} minuto(s).`
);
error.statusCode = 429;
throw error;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants