Limitação de tentativas de login
Parte da seção Autenticação e segurança do Journey de Node.js da Coddy. Lição 16 de 17.
Um script pode tentar milhares de senhas por minuto em uma conta. Conte as tentativas de login malsucedidas por nome de usuário e, depois de cinco em um minuto, recuse-se a verificar outras até o minuto acabar.
O status 429 Too Many Requests informa 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 na memória basta para um servidor; ele é esvaziado quando o servidor reinicia.
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());
// ... envia o e-mail
});Desafio
Fácil- Após 5 tentativas de login malsucedidas para o mesmo nome de usuário em um minuto,
POST /loginresponde com 429{ error }para esse nome, mesmo com a senha correta. - A resposta 429 inclui
Retry-After: os segundos restantes daquele minuto. - Logins bem-sucedidos nunca são contabilizados.
Cadastre-se, envie seis senhas incorretas e, em seguida, a senha correta.
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 se o usuário conectado não tiver 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
4Proprietários e Segurança
Somente suas anotaçõesFunções de administrador