Autenticación Segura
La autenticación verifica la identidad del usuario. Una implementación pobre de autenticación es una causa principal de brechas de seguridad. Exploremos las mejores prácticas.
Seguridad de Contraseñas
1. Hashing de Contraseñas con bcrypt
const bcrypt = require('bcrypt');
// ¡NUNCA almacenes contraseñas en texto plano!
// MAL:
const user = { password: req.body.password }; // NUNCA HAGAS ESTO
// BIEN: Hashea contraseñas antes de almacenar
async function hashPassword(password) {
const saltRounds = 12; // Mayor = más seguro pero más lento
return bcrypt.hash(password, saltRounds);
}
async function createUser(email, password) {
const passwordHash = await hashPassword(password);
return db.user.create({
data: {
email,
passwordHash, // Almacena el hash, nunca la contraseña plana
},
});
}
// Verificar contraseña
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
async function login(email, password) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// Usar mismo error para prevenir enumeración de usuarios
throw new Error('Credenciales inválidas');
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
throw new Error('Credenciales inválidas');
}
return user;
}
2. Requisitos de Contraseña
import { z } from 'zod';
const passwordSchema = z.string()
.min(12, 'La contraseña debe tener al menos 12 caracteres')
.regex(/[A-Z]/, 'La contraseña debe contener mayúscula')
.regex(/[a-z]/, 'La contraseña debe contener minúscula')
.regex(/[0-9]/, 'La contraseña debe contener número')
.regex(/[^A-Za-z0-9]/, 'La contraseña debe contener carácter especial')
.refine(
(pwd) => !commonPasswords.includes(pwd.toLowerCase()),
'La contraseña es muy común'
);
// Verificar contra contraseñas filtradas usando API Have I Been Pwned
async function isPasswordBreached(password) {
const crypto = require('crypto');
const hash = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const text = await response.text();
return text.includes(suffix);
}
Gestión de Sesiones
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const redisClient = redis.createClient();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
name: 'sessionId', // No usar el default 'connect.sid'
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Solo HTTPS
httpOnly: true, // Sin acceso JavaScript
sameSite: 'strict', // Protección CSRF
maxAge: 3600000, // 1 hora
},
}));
// Regenerar sesión al login para prevenir fijación
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Error de sesión' });
req.session.userId = user.id;
req.session.loginTime = Date.now();
res.json({ success: true });
});
});
// Destruir sesión al logout
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
res.clearCookie('sessionId');
res.json({ success: true });
});
});
Autenticación JWT
const jwt = require('jsonwebtoken');
// Generar tokens
function generateTokens(userId) {
const accessToken = jwt.sign(
{ userId, type: 'access' },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
// Verificar access token
function verifyAccessToken(token) {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
if (payload.type !== 'access') throw new Error('Tipo de token inválido');
return payload;
} catch (error) {
throw new Error('Token inválido');
}
}
// Middleware
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token no proporcionado' });
}
const token = authHeader.split(' ')[1];
try {
req.user = verifyAccessToken(token);
next();
} catch (error) {
res.status(401).json({ error: 'Token inválido' });
}
}
// Endpoint de refresh token
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// Verificar si token está revocado (almacenar en Redis/DB)
const isRevoked = await isTokenRevoked(refreshToken);
if (isRevoked) throw new Error('Token revocado');
const tokens = generateTokens(payload.userId);
res.json(tokens);
} catch (error) {
res.status(401).json({ error: 'Refresh token inválido' });
}
});
Autenticación Multifactor (MFA)
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Generar secreto TOTP
async function enableMFA(userId) {
const secret = speakeasy.generateSecret({
name: `MiApp (${user.email})`,
issuer: 'MiApp',
});
// Almacenar secreto de forma segura (encriptado)
await db.user.update({
where: { id: userId },
data: { mfaSecret: encrypt(secret.base32) },
});
// Generar código QR para app autenticadora
const qrCode = await QRCode.toDataURL(secret.otpauth_url);
return { secret: secret.base32, qrCode };
}
// Verificar código TOTP
function verifyMFACode(secret, code) {
return speakeasy.totp.verify({
secret,
encoding: 'base32',
token: code,
window: 1, // Permitir 1 paso antes/después
});
}
// Login con MFA
async function loginWithMFA(email, password, mfaCode) {
const user = await authenticate(email, password);
if (user.mfaEnabled) {
const secret = decrypt(user.mfaSecret);
const valid = verifyMFACode(secret, mfaCode);
if (!valid) {
throw new Error('Código MFA inválido');
}
}
return generateTokens(user.id);
}
Limitación de Tasa
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
// Limitación estricta para endpoints de auth
const authLimiter = rateLimit({
store: new RedisStore({ client: redisClient }),
windowMs: 15 * 60 * 1000, // 15 minutos
max: 5, // 5 intentos por ventana
message: { error: 'Demasiados intentos de login, intenta más tarde' },
standardHeaders: true,
legacyHeaders: false,
});
app.post('/login', authLimiter, loginHandler);
app.post('/register', authLimiter, registerHandler);
app.post('/forgot-password', authLimiter, forgotPasswordHandler);
Lista de Verificación de Seguridad
- Hashea contraseñas con bcrypt (factor de costo 12+)
- Aplica requisitos de contraseña fuertes
- Verifica contraseñas contra bases de datos de brechas
- Usa cookies de sesión seguras (httpOnly, secure, sameSite)
- Implementa access tokens de corta duración + refresh tokens
- Ofrece y fomenta MFA
- Limita la tasa de endpoints de autenticación
- Registra eventos de autenticación
- Usa bloqueo de cuenta después de intentos fallidos