fix(seguranca): limitar tentativas de login (forca bruta) - #23
Merged
Conversation
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.
Contributor
There was a problem hiding this comment.
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; | ||
| } |
This was referenced Aug 3, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.rateLimitpor rota):/login/magic-link/magic-verify/register2. 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
catchda 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.jse confirmei o comportamento real do@fastify/rate-limit10.x:Ou seja: o limite por rota entra em vigor e é isolado do global.
Test plan
node --checknos arquivos alterados