Implementación de la autenticación multiusuario
Al finalizar este tema
Podrás aplicar un hash seguro a las contraseñas durante el registro, comprender los principios de bcrypt e implementar el control de acceso basado en roles (RBAC).
Por qué no se deben almacenar las contraseñas en texto plano
// NEVER do this
const users = [
{ username: 'alice', password: 'mySecret123' }
];Si se filtra la base de datos, las contraseñas de todos los usuarios quedarán expuestas. El hackeo de LinkedIn en 2012 y el de Adobe en 2013 mostraron que cientos de millones de contraseñas se almacenaban en texto plano (o con hashes débiles).
Nunca almacene contraseñas en texto plano. Almacénelas mediante hashing.
¿Qué es el hashing?
El hashing es una transformación unidireccional. Es posible pasar del original al hash, pero no del hash al original.
"mySecret123" → hashing → "$2b$10$X7kG..." (posible)
"$2b$10$X7kG..." → ??? → "mySecret123" (imposible)Al iniciar sesión, se aplica un hash a la contraseña ingresada y se compara con el hash almacenado.
Entrada del usuario: "mySecret123"
Hash almacenado: "$2b$10$X7kG..."
"mySecret123" → hashing → "$2b$10$X7kG..." (coincidencia → autenticación exitosa)
"wrongPass" → hashing → "$2b$10$Qm1Y..." (no coincidencia → autenticación fallida)bcrypt: el estándar para el hashing de contraseñas
npm install bcryptRazones por las que bcrypt es más adecuado para contraseñas que las funciones hash genéricas como SHA-256:
- Diseñado para ser lento: Evita que un atacante intente miles de millones de hashes por segundo.
- Incluye salt automáticamente: Genera un hash diferente cada vez, incluso para la misma contraseña.
- Costo ajustable: Permite ajustar el equilibrio entre velocidad y seguridad mediante el valor de
rounds.
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 10;
// hashing (durante el registro)
const hash = await bcrypt.hash('mySecret123', SALT_ROUNDS);
console.log(hash);
// $2b$10$X7kG.fDlUqVnPq5RvWEYq.ZM3Y4Fs8qD1vSrKLhwEcFjnp2fW9Xm.
// comparación (durante el inicio de sesión)
const isMatch = await bcrypt.compare('mySecret123', hash);
console.log(isMatch); // true
const isWrong = await bcrypt.compare('wrongPass', hash);
console.log(isWrong); // falseSALT_ROUNDS = 10 indica que la operación se repite veces. Un valor más alto es más seguro, pero más lento. El rango recomendado actualmente es de 10 a 12.
Implementación del registro de usuarios
const express = require('express');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const users = []; // en la práctica se usa una base de datos
app.post('/register', async (req, res) => {
const { username, password, displayName } = req.body;
// validación de entrada
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
// Verificación de duplicados
if (users.find(u => u.username === username)) {
return res.status(409).json({ error: 'Username already exists' });
}
// Hashing de contraseña
const hashedPassword = await bcrypt.hash(password, 10);
// Guardar
const newUser = {
id: users.length + 1,
username,
password: hashedPassword,
displayName: displayName || username,
role: 'user',
createdAt: new Date().toISOString()
};
users.push(newUser);
res.status(201).json({
message: 'Registration successful',
user: { id: newUser.id, username: newUser.username }
});
});Nunca incluya contraseñas (incluyendo hashes) en las respuestas.
Integración con Passport
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
passport.use(new LocalStrategy(
async (username, password, done) => {
const user = users.find(u => u.username === username);
if (!user) {
return done(null, false, { message: 'User not found' });
}
// Comparación con bcrypt
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return done(null, false, { message: 'Wrong password' });
}
return done(null, user);
}
));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
const user = users.find(u => u.id === id);
done(null, user);
});La única diferencia con la Estrategia del tema anterior es que se utiliza bcrypt.compare() en lugar de user.password !== password.
Control de acceso basado en roles (RBAC)
function requireRole(...roles) {
return (req, res, next) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: 'Login required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Todos los usuarios logueados
app.get('/profile', requireRole('user', 'admin'), (req, res) => {
res.json({ user: req.user.displayName });
});
// Solo administradores
app.get('/admin/users', requireRole('admin'), (req, res) => {
const safeUsers = users.map(u => ({
id: u.id, username: u.username, role: u.role
}));
res.json(safeUsers);
});
// Administrador — Cambio de rol
app.patch('/admin/users/:id/role', requireRole('admin'), (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
user.role = req.body.role;
res.json({ message: `${user.username} role changed to ${user.role}` });
});requireRole(...roles) es una función de orden superior (una función que devuelve otra función). Recibe como argumento el rol permitido y crea un middleware que devuelve un error 403 si el rol no coincide.
Lista de verificación de seguridad
| Elemento | Descripción |
|---|---|
| Hash de contraseñas | bcrypt, SALT_ROUNDS 10+ |
| Longitud mínima | 8 caracteres o más (recomendación de NIST) |
| No incluir el hash en la respuesta | Nunca incluir el campo "password" en las respuestas de la API |
| Generalización de mensajes de error | "Nombre de usuario o contraseña incorrectos" (no indicar cuál de los dos es el error) |
| Limitación de tasa (Rate limiting) | Limitar el número de intentos de inicio de sesión (para prevenir ataques de fuerza bruta) |
| HTTPS | Para evitar que la contraseña se transmita en texto plano por la red |
Razón para generalizar los mensajes de error: si se indica "El nombre de usuario no existe", un atacante podría identificar cuentas existentes. Esto se conoce como enumeración de usuarios (User Enumeration).
Patrón práctico — Eliminación de cuenta
app.delete('/account', requireRole('user', 'admin'), async (req, res) => {
const valid = await bcrypt.compare(req.body.password, req.user.password);
if (!valid) {
return res.status(401).json({ error: 'Password confirmation failed' });
}
const index = users.findIndex(u => u.id === req.user.id);
users.splice(index, 1);
req.logout((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.json({ message: 'Account deleted' });
});
});La eliminación de cuentas es una operación crítica, por lo que se requiere una reconfirmación de la contraseña. Tras la eliminación, es imprescindible limpiar la sesión mediante req.logout().
Resumen clave
| Concepto | Resumen |
|---|---|
| Hashing | Transformación unidireccional. De original a hash es posible; de hash a original no. |
| bcrypt | Hash específico para contraseñas. Es lento e incluye salt. |
| Salt | Valor aleatorio que genera hashes distintos incluso para la misma contraseña. |
| RBAC | Control de acceso basado en roles. |
bcrypt.hash() | Durante el registro: contraseña hash. |
bcrypt.compare() | Durante el inicio de sesión: comparación entre la entrada y el hash almacenado. |
La autenticación va más allá de verificar si "la contraseña es correcta"; la clave reside en "cómo se gestiona la contraseña de forma segura". El almacenamiento en texto plano es un riesgo, los hashes débiles (MD5, SHA-1) son inútiles y bcrypt es el estándar actual.