Menu
CoddyTech

Limitação de tentativas de login

Parte da seção Autenticação e segurança do Journey de Node.js da Coddy. Lição 23 de 24.

Um script pode tentar milhares de senhas por minuto em uma única conta. Conte as tentativas de login malsucedidas por nome de usuário e, após cinco em um minuto, recuse-se a verificar outras até o minuto terminar.

O status 429 Too Many Requests explica o motivo, e o cabeçalho Retry-After informa quantos segundos esperar. Conte apenas as tentativas malsucedidas: uma pessoa que digita a senha correta nunca deve bloquear o próprio acesso. Um Map armazena valores por chave: map.get(key) lê um valor e map.set(key, value) grava um valor. Ele fica na memória, o que é suficiente para um servidor, e é esvaziado quando o servidor reinicia. Este exemplo permite um e-mail de redefinição por minuto, então armazena apenas um horário. Seu login conta as tentativas malsucedidas, então o registro mantém uma contagem e um horário.

const lastSent = new Map();

app.post('/reset', (req, res) => {
    const email = String(req.body.email ?? '').toLowerCase();
    const wait = (lastSent.get(email) ?? 0) + 60_000 - Date.now();
    if (wait > 0) {
        res.set('Retry-After', String(Math.ceil(wait / 1000)));
        return res.status(429).json({ error: 'One reset email a minute' });
    }
    lastSent.set(email, Date.now());
    // ... enviar o e-mail
});

Esta versão didática conta as tentativas malsucedidas concluídas em um único processo do servidor. Requisições simultâneas podem ultrapassar o limite enquanto as verificações de senha estão pendentes; um limitador de produção precisa contar uma tentativa antes de verificar a senha e manter as contagens em um armazenamento compartilhado por todos os servidores.

challenge icon

Desafio

Fácil

Esta etapa abre o projeto de API de anotações privadas preparado para o curso, em vez dos arquivos do seu projeto de Security Headers (incluindo server.js). Seu trabalho anterior continua na própria lição. Leia os arquivos fornecidos antes de editar server.js. Esta cópia contém os filtros de proprietário das anotações privadas, as verificações de função e os cabeçalhos de segurança.

Um script pode tentar milhares de senhas por minuto contra uma conta. Desacelere-o: após 5 senhas erradas para um nome de usuário em um minuto, pare de verificar senhas para esse nome até o minuto acabar.

  1. Em server.js, acima de POST /login, mantenha um registro das tentativas de login malsucedidas para cada nome de usuário em um Map. Para cada nome, armazene quantas falhas ocorreram e quando aconteceu a primeira.
  2. Em POST /login, antes de verificar a senha, procure o registro do nome de usuário. Um registro cuja primeira falha ocorreu há um minuto ou mais conta como se não existisse. Quando o nome tiver 5 falhas dentro de um minuto, responda com o status 429 e {"error": "Too many failed logins. Try again later."}. Faça isso mesmo quando a senha estiver correta. 429 Too Many Requests significa “desacelere”.
  3. Nessa resposta 429, defina o cabeçalho Retry-After com o número inteiro de segundos que faltam até completar um minuto desde a primeira falha, por exemplo, Retry-After: 42.
  4. Quando a senha estiver errada, adicione 1 às falhas desse nome de usuário e, em seguida, responda com o 401 habitual. A primeira falha, ou a primeira após um registro antigo, inicia uma nova contagem em 1 com o horário atual.
  5. Nunca conte um login bem-sucedido. As falhas de um nome de usuário nunca limitam outro.

Teste: pressione Executar.

  • Envie POST /signup com {"username": "ada", "password": "correct horse"}.
  • Envie POST /login com {"username": "ada", "password": "guess"} cinco vezes: status 401 a cada vez.
  • Envie pela sexta vez: status 429. A aba Headers mostra Retry-After com um número de segundos, no máximo 60.
  • Envie a senha correta, {"username": "ada", "password": "correct horse"}: ainda recebe 429.
  • Envie POST /signup e, em seguida, POST /login, com {"username": "bob", "password": "correct horse"}: status 200, porque apenas ada está limitada. Depois de um minuto, ada pode fazer login novamente.

Experimente você mesmo

import express from 'express';
import { ObjectId } from 'mongodb';
import { randomBytes } from 'node:crypto';
import { parseCookies } from './cookies.js';
import { notes, sessions, users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';

const app = express();
app.disable('x-powered-by');
app.use(express.json());

// Cabeçalhos que instruem o navegador a tratar cada resposta com cuidado.
app.use((req, res, next) => {
    res.set({
        'X-Content-Type-Options': 'nosniff',
        'Referrer-Policy': 'no-referrer',
        'Content-Security-Policy': "default-src 'self'",
    });
    next();
});

// A única conta de administrador. Sua senha vem do ambiente.
await users.insertOne({ username: 'admin', role: 'admin', passwordHash: await hashPassword(process.env.ADMIN_PASSWORD) });

app.post('/signup', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const password = String(req.body.password ?? '');
    if (!username || password.length < 8) {
        return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
    }
    if (await users.findOne({ username })) {
        return res.status(409).json({ error: 'That username is taken' });
    }
    await users.insertOne({ username, role: 'member', passwordHash: await hashPassword(password) });
    res.status(201).json({ username });
});

app.post('/login', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const user = await users.findOne({ username });
    if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
        return res.status(401).json({ error: 'Wrong username or password' });
    }
    const sid = randomBytes(24).toString('base64url');
    await sessions.insertOne({ _id: sid, userId: user._id, createdAt: new Date() });
    res.cookie('sid', sid, {
        httpOnly: true,
        sameSite: 'lax',
        secure: true,
        maxAge: 24 * 60 * 60 * 1000,
    });
    res.json({ username: user.username });
});

// Encontra o usuário associado ao cookie de sessão ou responde com 401.
async function requireUser(req, res, next) {
    const { sid } = parseCookies(req.get('cookie'));
    const session = sid && (await sessions.findOne({ _id: sid }));
    const user = session && (await users.findOne({ _id: session.userId }));
    if (!user) {
        return res.status(401).json({ error: 'Log in first' });
    }
    req.user = { id: user._id.toHexString(), username: user.username, role: user.role };
    next();
}

// Responde com 403, a menos que o usuário conectado tenha esta função.
function requireRole(role) {
    return (req, res, next) => {
        if (req.user.role !== role) {
            return res.status(403).json({ error: 'Not allowed' });
        }
        next();
    };
}

app.get('/me', requireUser, (req, res) => {
    res.json({ username: req.user.username });
});

app.post('/logout', async (req, res) => {
    const { sid } = parseCookies(req.get('cookie'));
    if (sid) {
        await sessions.deleteOne({ _id: sid });
    }
    res.clearCookie('sid');
    res.status(204).end();
});

// Suas notas em uma página.
app.get('/', requireUser, async (req, res) => {
    const list = await notes.find({ owner: req.user.id }).toArray();
    const items = list.map((note) => `<li>${note.text}</li>`).join('');
    res.send(`<!doctype html><title>Vault</title><h1>${req.user.username}'s notes</h1><ul>${items}</ul>`);
});

// Notas: cada usuário lê e escreve apenas as suas próprias.
app.get('/notes', requireUser, async (req, res) => {
    res.json(await notes.find({ owner: req.user.id }).toArray());
});

app.post('/notes', requireUser, async (req, res) => {
    const note = { owner: req.user.id, text: String(req.body.text ?? '') };
    await notes.insertOne(note);
    res.status(201).json(note);
});

app.get('/notes/:id', requireUser, async (req, res) => {
    if (!ObjectId.isValid(req.params.id)) {
        return res.status(404).json({ error: 'Note not found' });
    }
    const note = await notes.findOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
    if (!note) {
        return res.status(404).json({ error: 'Note not found' });
    }
    res.json(note);
});

app.delete('/notes/:id', requireUser, async (req, res) => {
    if (!ObjectId.isValid(req.params.id)) {
        return res.status(404).json({ error: 'Note not found' });
    }
    const { deletedCount } = await notes.deleteOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
    if (!deletedCount) {
        return res.status(404).json({ error: 'Note not found' });
    }
    res.status(204).end();
});

// Para administradores: quantas contas existem.
app.get('/admin', requireUser, requireRole('admin'), async (req, res) => {
    res.json({ users: await users.countDocuments() });
});

app.listen(3000, () => {
    console.log('Vault is running on port 3000');
});

Todas as lições de Autenticação e segurança

Pratique por conta própria: Playground de Express