167 lines
4.3 KiB
TypeScript
167 lines
4.3 KiB
TypeScript
|
|
/**
|
||
|
|
* Rutas de autenticación
|
||
|
|
*/
|
||
|
|
|
||
|
|
import express, { Request, Response } from 'express';
|
||
|
|
import bcrypt from 'bcrypt';
|
||
|
|
import jwt from 'jsonwebtoken';
|
||
|
|
import { query } from '../../config/database.js';
|
||
|
|
import { validateSecurityConfig } from '../config/security.js';
|
||
|
|
import { authLimiter } from '../middleware/rate-limit.js';
|
||
|
|
import { validateBody } from '../middleware/validate.js';
|
||
|
|
import { loginSchema } from '../validators/auth.js'; // TypeScript
|
||
|
|
|
||
|
|
const router = express.Router();
|
||
|
|
|
||
|
|
// ✅ VALIDACIÓN DE JWT_SECRET (sin fallback débil)
|
||
|
|
const securityConfig = validateSecurityConfig();
|
||
|
|
const JWT_SECRET: string = securityConfig.JWT_SECRET;
|
||
|
|
const JWT_EXPIRES_IN: string = securityConfig.JWT_EXPIRES_IN;
|
||
|
|
|
||
|
|
interface LoginRequest {
|
||
|
|
email: string;
|
||
|
|
password: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface LoginResponse {
|
||
|
|
token: string;
|
||
|
|
user: {
|
||
|
|
id: string;
|
||
|
|
email: string;
|
||
|
|
username: string;
|
||
|
|
role: string;
|
||
|
|
};
|
||
|
|
expiresIn: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface UserRow {
|
||
|
|
id: string;
|
||
|
|
email: string;
|
||
|
|
username: string;
|
||
|
|
password_hash: string;
|
||
|
|
role: string;
|
||
|
|
is_active: boolean;
|
||
|
|
created_at?: Date;
|
||
|
|
last_login?: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface JwtPayload {
|
||
|
|
userId: string;
|
||
|
|
email: string;
|
||
|
|
role: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* POST /api/auth/login
|
||
|
|
* Login de usuario
|
||
|
|
* ✅ Rate limiting: 5 intentos por 15 minutos por IP
|
||
|
|
* ✅ Validación de inputs con Zod
|
||
|
|
*/
|
||
|
|
router.post('/login', authLimiter, validateBody(loginSchema), async (req: Request<{}, LoginResponse, LoginRequest>, res: Response<LoginResponse | { error: string }>) => {
|
||
|
|
try {
|
||
|
|
// ✅ Datos ya validados por Zod middleware
|
||
|
|
const { email, password } = req.body;
|
||
|
|
|
||
|
|
// Buscar usuario
|
||
|
|
const result = await query(
|
||
|
|
`SELECT id, email, username, password_hash, role, is_active
|
||
|
|
FROM tes_content.users
|
||
|
|
WHERE email = $1`,
|
||
|
|
[email.toLowerCase()]
|
||
|
|
);
|
||
|
|
|
||
|
|
if (result.rows.length === 0) {
|
||
|
|
res.status(401).json({ error: 'Credenciales inválidas' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const user = result.rows[0] as UserRow;
|
||
|
|
|
||
|
|
if (!user.is_active) {
|
||
|
|
res.status(401).json({ error: 'Usuario inactivo' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verificar contraseña
|
||
|
|
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||
|
|
if (!validPassword) {
|
||
|
|
res.status(401).json({ error: 'Credenciales inválidas' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Actualizar último login
|
||
|
|
await query(
|
||
|
|
`UPDATE tes_content.users
|
||
|
|
SET last_login = NOW()
|
||
|
|
WHERE id = $1`,
|
||
|
|
[user.id]
|
||
|
|
);
|
||
|
|
|
||
|
|
// Generar token
|
||
|
|
// JWT_SECRET está validado al startup, nunca será vacío
|
||
|
|
if (!JWT_SECRET || JWT_SECRET.length === 0) {
|
||
|
|
throw new Error('JWT_SECRET no configurado');
|
||
|
|
}
|
||
|
|
const payload: JwtPayload = { userId: user.id, email: user.email, role: user.role };
|
||
|
|
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN } as jwt.SignOptions);
|
||
|
|
|
||
|
|
// Retornar respuesta
|
||
|
|
res.json({
|
||
|
|
token,
|
||
|
|
user: {
|
||
|
|
id: user.id,
|
||
|
|
email: user.email,
|
||
|
|
username: user.username,
|
||
|
|
role: user.role,
|
||
|
|
},
|
||
|
|
expiresIn: 24 * 60 * 60, // 24 horas en segundos
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error en login:', error);
|
||
|
|
res.status(500).json({ error: 'Error interno del servidor' });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* GET /api/auth/me
|
||
|
|
* Obtener información del usuario actual
|
||
|
|
*/
|
||
|
|
router.get('/me', async (req: Request, res: Response) => {
|
||
|
|
try {
|
||
|
|
const authHeader = req.headers.authorization;
|
||
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||
|
|
res.status(401).json({ error: 'Token no proporcionado' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const token = authHeader.substring(7);
|
||
|
|
// ✅ JWT_SECRET validado al startup, sin fallback débil
|
||
|
|
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
|
||
|
|
|
||
|
|
const result = await query(
|
||
|
|
`SELECT id, email, username, role, is_active, created_at, last_login
|
||
|
|
FROM tes_content.users
|
||
|
|
WHERE id = $1`,
|
||
|
|
[decoded.userId]
|
||
|
|
);
|
||
|
|
|
||
|
|
if (result.rows.length === 0) {
|
||
|
|
res.status(401).json({ error: 'Usuario no encontrado' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
res.json({ user: result.rows[0] as UserRow });
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof Error) {
|
||
|
|
if (error.name === 'JsonWebTokenError') {
|
||
|
|
res.status(401).json({ error: 'Token inválido' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
res.status(500).json({ error: 'Error interno del servidor' });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
export default router;
|
||
|
|
|