Limitar la frecuencia de inicios de sesión
Parte de la sección Autenticación y seguridad del Journey de Node.js de Coddy. Lección 23 de 24.
Un script puede probar miles de contraseñas por minuto contra una cuenta. Cuenta los inicios de sesión fallidos por nombre de usuario y, después de cinco en un minuto, rechaza comprobar más hasta que termine el minuto.
El estado 429 Too Many Requests explica el motivo, y el encabezado Retry-After indica cuántos segundos hay que esperar. Cuenta solo los intentos fallidos: una persona que escriba la contraseña correcta nunca debe quedarse sin poder acceder a su cuenta. Un Map almacena valores por clave: map.get(key) lee uno y map.set(key, value) escribe uno. Vive en la memoria, lo cual basta para un servidor, y se vacía cuando se reinicia el servidor. Este ejemplo permite enviar un correo electrónico para restablecer la contraseña por minuto, así que solo guarda una hora. Tu inicio de sesión cuenta los intentos fallidos, así que su registro guarda un contador y una hora.
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());
// ... envía el correo electrónico
});Esta versión didáctica cuenta los intentos fallidos completados en un único proceso del servidor. Las solicitudes simultáneas pueden superar el límite mientras las comprobaciones de contraseña están pendientes; un limitador para producción debe contar un intento antes de comprobar la contraseña y mantener los contadores en un almacén compartido por todos los servidores.
Desafío
FácilEste paso abre el proyecto de API de notas privadas preparado para el curso, en lugar de los archivos de tu proyecto de Security Headers (incluido server.js). Tu trabajo anterior sigue en su propia lección. Lee los archivos proporcionados antes de editar server.js. Esta copia contiene los filtros de propietario de las notas privadas, las comprobaciones de roles y los encabezados de seguridad.
Un script puede probar miles de contraseñas por minuto contra una cuenta. Ponle freno: después de 5 contraseñas incorrectas para un mismo nombre de usuario en un minuto, deja de comprobar las contraseñas de ese nombre hasta que se cumpla el minuto.
- En
server.js, encima dePOST /login, lleva un registro de los intentos fallidos de inicio de sesión para cada nombre de usuario en unMap. Para cada nombre, guarda cuántos intentos fallidos hubo y cuándo ocurrió el primero. - En
POST /login, antes de comprobar la contraseña, busca el registro del nombre de usuario. Un registro cuyo primer intento fallido ocurrió hace un minuto o más cuenta como si no hubiera registro. Cuando el nombre tenga 5 intentos fallidos en el transcurso del minuto, responde con el estado 429 y{"error": "Too many failed logins. Try again later."}. Hazlo incluso cuando la contraseña fuera correcta. 429 Demasiadas solicitudes significa «ve más despacio». - En ese 429, establece el encabezado
Retry-Aftercon el número entero de segundos que faltan para que haya pasado un minuto desde el primer intento fallido, por ejemplo,Retry-After: 42. - Cuando la contraseña sea incorrecta, suma 1 a los intentos fallidos de ese nombre de usuario y luego responde con el 401 habitual. El primer intento fallido, o el primero después de que un registro haya caducado, inicia una nueva cuenta de 1 con la hora actual.
- Nunca cuentes un inicio de sesión correcto. Los intentos fallidos de un nombre de usuario nunca limitan a otro.
Pruébalo: Pulsa «Ejecutar».
- Envía
POST /signupcon{"username": "ada", "password": "correct horse"}. - Envía
POST /logincon{"username": "ada", "password": "guess"}cinco veces: el estado será 401 cada vez. - Envíalo una sexta vez: el estado será 429. La pestaña Headers muestra
Retry-Aftercon un número de segundos, como máximo 60. - Envía la contraseña correcta,
{"username": "ada", "password": "correct horse"}: seguirá respondiendo 429. - Envía
POST /signupy luegoPOST /logincon{"username": "bob", "password": "correct horse"}: el estado será 200, porque solo ada tiene restricciones. Después de un minuto, ada podrá iniciar sesión de nuevo.
Pruébalo tú mismo
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());
// Cabeceras que indican al navegador que tenga cuidado con cada respuesta.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// La única cuenta de administrador. Su contraseña proviene del entorno.
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 });
});
// Busca al usuario de la cookie de sesión o responde con 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 con 403 a menos que el usuario que ha iniciado sesión tenga este rol.
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();
});
// Tus notas en una 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 usuario solo lee y escribe las suyas.
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: cuántas cuentas hay.
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 las lecciones de Autenticación y seguridad
1Contraseñas
Bytes y texto hexadecimalDerivar una clave de contraseñaUna sal nuevaNunca almacenes contraseñasComparar bytes de claveVerificar una contraseñaReglas de registro4Propietarios y seguridad
Solo tus notasRoles de administraciónPractica por tu cuenta: Playground de Express