security: comprehensive security audit and remediation (20 fixes)
CRITICAL fixes: - Restrict X-View-Tenant impersonation to global admin only (was any admin) - Add authorization to subscription endpoints (was open to any user) - Make webhook signature verification mandatory (was skippable) - Remove databaseName from JWT payload (resolve server-side with cache) - Reduce body size limit from 1GB to 10MB (50MB for bulk CFDI) - Restrict .env file permissions to 600 HIGH fixes: - Add authorization to SAT cron endpoints (global admin only) - Add Content-Security-Policy and Permissions-Policy headers - Centralize isGlobalAdmin() utility with caching - Add rate limiting on auth endpoints (express-rate-limit) - Require authentication on logout endpoint MEDIUM fixes: - Replace Math.random() with crypto.randomBytes for temp passwords - Remove console.log of temporary passwords in production - Remove DB credentials from admin notification email - Add escapeHtml() to email templates (prevent HTML injection) - Add file size validation on FIEL upload (50KB max) - Require TLS for SMTP connections - Normalize email to lowercase before uniqueness check - Remove hardcoded default for FIEL_ENCRYPTION_KEY Also includes: - Complete production deployment documentation - API reference documentation - Security audit report with remediation details - Updated README with v0.5.0 changelog - New client admin email template - Utility scripts (create-carlos, test-emails) - PM2 ecosystem config updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/pg": "^8.18.0",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.3.0"
|
||||
|
||||
26
apps/api/scripts/create-carlos.ts
Normal file
26
apps/api/scripts/create-carlos.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { hashPassword } from '../src/utils/password.js';
|
||||
|
||||
async function main() {
|
||||
const ivan = await prisma.user.findUnique({ where: { email: 'ivan@horuxfin.com' }, include: { tenant: true } });
|
||||
if (!ivan) { console.error('Ivan not found'); process.exit(1); }
|
||||
|
||||
console.log('Tenant:', ivan.tenant.nombre, '(', ivan.tenant.id, ')');
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: 'carlos@horuxfin.com' } });
|
||||
if (existing) { console.log('Carlos already exists:', existing.id); process.exit(0); }
|
||||
|
||||
const hash = await hashPassword('Aasi940812');
|
||||
const carlos = await prisma.user.create({
|
||||
data: {
|
||||
tenantId: ivan.tenantId,
|
||||
email: 'carlos@horuxfin.com',
|
||||
passwordHash: hash,
|
||||
nombre: 'Carlos Horux',
|
||||
role: 'admin',
|
||||
}
|
||||
});
|
||||
console.log('Carlos created:', carlos.id, carlos.email, carlos.role);
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });
|
||||
96
apps/api/scripts/test-emails.ts
Normal file
96
apps/api/scripts/test-emails.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { emailService } from '../src/services/email/email.service.js';
|
||||
|
||||
const recipients = ['ivan@horuxfin.com', 'carlos@horuxfin.com'];
|
||||
|
||||
async function sendAllSamples() {
|
||||
for (const to of recipients) {
|
||||
console.log(`\n=== Enviando a ${to} ===`);
|
||||
|
||||
// 1. Welcome
|
||||
console.log('1/6 Bienvenida...');
|
||||
await emailService.sendWelcome(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
email: 'ivan@horuxfin.com',
|
||||
tempPassword: 'TempPass123!',
|
||||
});
|
||||
|
||||
// 2. FIEL notification (goes to ADMIN_EMAIL, but we override for test)
|
||||
console.log('2/6 Notificación FIEL...');
|
||||
// Send directly since sendFielNotification goes to admin
|
||||
const { fielNotificationEmail } = await import('../src/services/email/templates/fiel-notification.js');
|
||||
const { createTransport } = await import('nodemailer');
|
||||
const { env } = await import('../src/config/env.js');
|
||||
const transport = createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT),
|
||||
secure: false,
|
||||
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
|
||||
});
|
||||
const fielHtml = fielNotificationEmail({
|
||||
clienteNombre: 'Consultoria Alcaraz Salazar',
|
||||
clienteRfc: 'CAS200101XXX',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: '[Consultoria Alcaraz Salazar] subió su FIEL (MUESTRA)',
|
||||
html: fielHtml,
|
||||
});
|
||||
|
||||
// 3. Payment confirmed
|
||||
console.log('3/6 Pago confirmado...');
|
||||
await emailService.sendPaymentConfirmed(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
amount: 1499,
|
||||
plan: 'Enterprise',
|
||||
date: '16 de marzo de 2026',
|
||||
});
|
||||
|
||||
// 4. Payment failed
|
||||
console.log('4/6 Pago fallido...');
|
||||
const { paymentFailedEmail } = await import('../src/services/email/templates/payment-failed.js');
|
||||
const failedHtml = paymentFailedEmail({
|
||||
nombre: 'Ivan Alcaraz',
|
||||
amount: 1499,
|
||||
plan: 'Enterprise',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: 'Problema con tu pago - Horux360 (MUESTRA)',
|
||||
html: failedHtml,
|
||||
});
|
||||
|
||||
// 5. Subscription expiring
|
||||
console.log('5/6 Suscripción por vencer...');
|
||||
await emailService.sendSubscriptionExpiring(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
plan: 'Enterprise',
|
||||
expiresAt: '21 de marzo de 2026',
|
||||
});
|
||||
|
||||
// 6. Subscription cancelled
|
||||
console.log('6/6 Suscripción cancelada...');
|
||||
const { subscriptionCancelledEmail } = await import('../src/services/email/templates/subscription-cancelled.js');
|
||||
const cancelledHtml = subscriptionCancelledEmail({
|
||||
nombre: 'Ivan Alcaraz',
|
||||
plan: 'Enterprise',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: 'Suscripción cancelada - Horux360 (MUESTRA)',
|
||||
html: cancelledHtml,
|
||||
});
|
||||
|
||||
console.log(`Listo: 6 correos enviados a ${to}`);
|
||||
}
|
||||
|
||||
console.log('\n=== Todos los correos enviados ===');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
sendAllSamples().catch((err) => {
|
||||
console.error('Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -27,9 +27,9 @@ app.use(cors({
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Body parsing - increased limit for bulk XML uploads (1GB)
|
||||
app.use(express.json({ limit: '1gb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '1gb' }));
|
||||
// Body parsing - 10MB default, bulk CFDI route has its own higher limit
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
@@ -15,10 +15,10 @@ const envSchema = z.object({
|
||||
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
||||
|
||||
// Frontend URL (for MercadoPago back_url, emails, etc.)
|
||||
FRONTEND_URL: z.string().default('https://horux360.consultoria-as.com'),
|
||||
FRONTEND_URL: z.string().default('https://horuxfin.com'),
|
||||
|
||||
// FIEL encryption (separate from JWT to allow independent rotation)
|
||||
FIEL_ENCRYPTION_KEY: z.string().min(32).default('dev-fiel-encryption-key-min-32-chars!!'),
|
||||
FIEL_ENCRYPTION_KEY: z.string().min(32),
|
||||
FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'),
|
||||
|
||||
// MercadoPago
|
||||
|
||||
@@ -16,6 +16,18 @@ export async function upload(req: Request, res: Response): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file sizes (typical .cer/.key files are under 10KB, base64 ~33% larger)
|
||||
const MAX_FILE_SIZE = 50_000; // 50KB base64 ≈ ~37KB binary
|
||||
if (cerFile.length > MAX_FILE_SIZE || keyFile.length > MAX_FILE_SIZE) {
|
||||
res.status(400).json({ error: 'Los archivos FIEL son demasiado grandes (máx 50KB)' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length > 256) {
|
||||
res.status(400).json({ error: 'Contraseña FIEL demasiado larga' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await uploadFiel(tenantId, cerFile, keyFile, password);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../services/sat/sat.service.js';
|
||||
import { getJobInfo, runSatSyncJobManually } from '../jobs/sat-sync.job.js';
|
||||
import type { StartSyncRequest } from '@horux/shared';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
/**
|
||||
* Inicia una sincronización manual
|
||||
@@ -121,10 +122,14 @@ export async function retry(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene información del job programado (solo admin)
|
||||
* Obtiene información del job programado (solo admin global)
|
||||
*/
|
||||
export async function cronInfo(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
|
||||
res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' });
|
||||
return;
|
||||
}
|
||||
const info = getJobInfo();
|
||||
res.json(info);
|
||||
} catch (error: any) {
|
||||
@@ -134,10 +139,14 @@ export async function cronInfo(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta el job de sincronización manualmente (solo admin)
|
||||
* Ejecuta el job de sincronización manualmente (solo admin global)
|
||||
*/
|
||||
export async function runCron(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
|
||||
res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' });
|
||||
return;
|
||||
}
|
||||
// Ejecutar en background
|
||||
runSatSyncJobManually().catch(err =>
|
||||
console.error('[SAT Controller] Error ejecutando cron manual:', err)
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as subscriptionService from '../services/payment/subscription.service.js';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
|
||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||
if (!isAdmin) {
|
||||
res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' });
|
||||
}
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
export async function getSubscription(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requireGlobalAdmin(req, res))) return;
|
||||
|
||||
const tenantId = String(req.params.tenantId);
|
||||
const subscription = await subscriptionService.getActiveSubscription(tenantId);
|
||||
if (!subscription) {
|
||||
@@ -16,6 +27,8 @@ export async function getSubscription(req: Request, res: Response, next: NextFun
|
||||
|
||||
export async function generatePaymentLink(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requireGlobalAdmin(req, res))) return;
|
||||
|
||||
const tenantId = String(req.params.tenantId);
|
||||
const result = await subscriptionService.generatePaymentLink(tenantId);
|
||||
res.json(result);
|
||||
@@ -26,6 +39,8 @@ export async function generatePaymentLink(req: Request, res: Response, next: Nex
|
||||
|
||||
export async function markAsPaid(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requireGlobalAdmin(req, res))) return;
|
||||
|
||||
const tenantId = String(req.params.tenantId);
|
||||
const { amount } = req.body;
|
||||
|
||||
@@ -42,6 +57,8 @@ export async function markAsPaid(req: Request, res: Response, next: NextFunction
|
||||
|
||||
export async function getPayments(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await requireGlobalAdmin(req, res))) return;
|
||||
|
||||
const tenantId = String(req.params.tenantId);
|
||||
const payments = await subscriptionService.getPaymentHistory(tenantId);
|
||||
res.json(payments);
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as usuariosService from '../services/usuarios.service.js';
|
||||
import { AppError } from '../utils/errors.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
// RFC del tenant administrador global
|
||||
const ADMIN_TENANT_RFC = 'CAS2408138W2';
|
||||
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
||||
if (req.user!.role !== 'admin') return false;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: req.user!.tenantId },
|
||||
select: { rfc: true },
|
||||
});
|
||||
|
||||
return tenant?.rfc === ADMIN_TENANT_RFC;
|
||||
return checkGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||
}
|
||||
|
||||
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
@@ -9,13 +9,16 @@ export async function handleMercadoPagoWebhook(req: Request, res: Response, next
|
||||
const xSignature = req.headers['x-signature'] as string;
|
||||
const xRequestId = req.headers['x-request-id'] as string;
|
||||
|
||||
// Verify webhook signature
|
||||
if (xSignature && xRequestId && data?.id) {
|
||||
const isValid = mpService.verifyWebhookSignature(xSignature, xRequestId, String(data.id));
|
||||
if (!isValid) {
|
||||
console.warn('[WEBHOOK] Invalid MercadoPago signature');
|
||||
return res.status(401).json({ message: 'Invalid signature' });
|
||||
}
|
||||
// Verify webhook signature (mandatory)
|
||||
if (!xSignature || !xRequestId || !data?.id) {
|
||||
console.warn('[WEBHOOK] Missing signature headers');
|
||||
return res.status(401).json({ message: 'Missing signature headers' });
|
||||
}
|
||||
|
||||
const isValid = mpService.verifyWebhookSignature(xSignature, xRequestId, String(data.id));
|
||||
if (!isValid) {
|
||||
console.warn('[WEBHOOK] Invalid MercadoPago signature');
|
||||
return res.status(401).json({ message: 'Invalid signature' });
|
||||
}
|
||||
|
||||
if (type === 'payment') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
// Simple in-memory cache with TTL
|
||||
const cache = new Map<string, { data: any; expires: number }>();
|
||||
@@ -24,8 +25,8 @@ export function invalidateTenantCache(tenantId: string) {
|
||||
export async function checkPlanLimits(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.user) return next();
|
||||
|
||||
// Admin impersonation bypasses subscription check
|
||||
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
|
||||
// Global admin impersonation bypasses subscription check
|
||||
if (req.headers['x-view-tenant'] && await isGlobalAdmin(req.user.tenantId, req.user.role)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
@@ -11,6 +12,30 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache: tenantId -> { databaseName, expires }
|
||||
const tenantDbCache = new Map<string, { databaseName: string; expires: number }>();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
async function getTenantDatabaseName(tenantId: string): Promise<string | null> {
|
||||
const cached = tenantDbCache.get(tenantId);
|
||||
if (cached && cached.expires > Date.now()) return cached.databaseName;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
|
||||
if (tenant) {
|
||||
tenantDbCache.set(tenantId, { databaseName: tenant.databaseName, expires: Date.now() + CACHE_TTL });
|
||||
}
|
||||
|
||||
return tenant?.databaseName ?? null;
|
||||
}
|
||||
|
||||
export function invalidateTenantDbCache(tenantId: string) {
|
||||
tenantDbCache.delete(tenantId);
|
||||
}
|
||||
|
||||
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
@@ -18,11 +43,15 @@ export async function tenantMiddleware(req: Request, res: Response, next: NextFu
|
||||
}
|
||||
|
||||
let tenantId = req.user.tenantId;
|
||||
let databaseName = req.user.databaseName;
|
||||
|
||||
// Admin impersonation via X-View-Tenant header
|
||||
// Admin impersonation via X-View-Tenant header (global admin only)
|
||||
const viewTenantHeader = req.headers['x-view-tenant'] as string;
|
||||
if (viewTenantHeader && req.user.role === 'admin') {
|
||||
if (viewTenantHeader) {
|
||||
const globalAdmin = await isGlobalAdmin(req.user.tenantId, req.user.role);
|
||||
if (!globalAdmin) {
|
||||
return res.status(403).json({ message: 'No autorizado para ver otros tenants' });
|
||||
}
|
||||
|
||||
const viewedTenant = await prisma.tenant.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
@@ -42,8 +71,15 @@ export async function tenantMiddleware(req: Request, res: Response, next: NextFu
|
||||
}
|
||||
|
||||
tenantId = viewedTenant.id;
|
||||
databaseName = viewedTenant.databaseName;
|
||||
req.viewingTenantId = viewedTenant.id;
|
||||
req.tenantPool = tenantDb.getPool(tenantId, viewedTenant.databaseName);
|
||||
return next();
|
||||
}
|
||||
|
||||
// Normal flow: look up databaseName server-side (not from JWT)
|
||||
const databaseName = await getTenantDatabaseName(tenantId);
|
||||
if (!databaseName) {
|
||||
return res.status(404).json({ message: 'Tenant no encontrado' });
|
||||
}
|
||||
|
||||
req.tenantPool = tenantDb.getPool(tenantId, databaseName);
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import * as authController from '../controllers/auth.controller.js';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.post('/register', authController.register);
|
||||
router.post('/login', authController.login);
|
||||
router.post('/refresh', authController.refresh);
|
||||
router.post('/logout', authController.logout);
|
||||
// Rate limiting: 10 login attempts per 15 minutes per IP
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Rate limiting: 3 registrations per hour per IP
|
||||
const registerLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 3,
|
||||
message: { message: 'Demasiados registros. Intenta de nuevo en 1 hora.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Rate limiting: 20 refresh attempts per 15 minutes per IP
|
||||
const refreshLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
message: { message: 'Demasiadas solicitudes. Intenta de nuevo más tarde.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
router.post('/register', registerLimiter, authController.register);
|
||||
router.post('/login', loginLimiter, authController.login);
|
||||
router.post('/refresh', refreshLimiter, authController.refresh);
|
||||
router.post('/logout', authenticate, authController.logout);
|
||||
router.get('/me', authenticate, authController.me);
|
||||
|
||||
export { router as authRoutes };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import { checkPlanLimits, checkCfdiLimit } from '../middlewares/plan-limits.middleware.js';
|
||||
@@ -17,7 +18,7 @@ router.get('/receptores', cfdiController.getReceptores);
|
||||
router.get('/:id', cfdiController.getCfdiById);
|
||||
router.get('/:id/xml', cfdiController.getXml);
|
||||
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
|
||||
router.post('/bulk', checkCfdiLimit, cfdiController.createManyCfdis);
|
||||
router.post('/bulk', express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
|
||||
router.delete('/:id', cfdiController.deleteCfdi);
|
||||
|
||||
export { router as cfdiRoutes };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import * as satController from '../controllers/sat.controller.js';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -22,10 +22,8 @@ router.get('/sync/:id', satController.jobDetail);
|
||||
// POST /api/sat/sync/:id/retry - Reintentar job fallido
|
||||
router.post('/sync/:id/retry', satController.retry);
|
||||
|
||||
// GET /api/sat/cron - Información del job programado (admin)
|
||||
router.get('/cron', satController.cronInfo);
|
||||
|
||||
// POST /api/sat/cron/run - Ejecutar job manualmente (admin)
|
||||
router.post('/cron/run', satController.runCron);
|
||||
// Admin-only cron endpoints (global admin verified in controller)
|
||||
router.get('/cron', authorize('admin'), satController.cronInfo);
|
||||
router.post('/cron/run', authorize('admin'), satController.runCron);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
|
||||
import * as subscriptionController from '../controllers/subscription.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// All endpoints require authentication
|
||||
// All endpoints require authentication + admin role
|
||||
router.use(authenticate);
|
||||
router.use(authorize('admin'));
|
||||
|
||||
// Admin subscription management
|
||||
// Admin subscription management (global admin verified in controller)
|
||||
router.get('/:tenantId', subscriptionController.getSubscription);
|
||||
router.post('/:tenantId/generate-link', subscriptionController.generatePaymentLink);
|
||||
router.post('/:tenantId/mark-paid', subscriptionController.markAsPaid);
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared
|
||||
|
||||
export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: data.usuario.email },
|
||||
where: { email: data.usuario.email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
@@ -52,7 +52,6 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: tenant.id,
|
||||
databaseName: tenant.databaseName,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
@@ -116,7 +115,6 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
databaseName: user.tenant.databaseName,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
@@ -181,7 +179,6 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
databaseName: user.tenant.databaseName,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(newTokenPayload);
|
||||
|
||||
@@ -18,7 +18,8 @@ function getTransporter(): Transporter {
|
||||
transporter = createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT),
|
||||
secure: false, // STARTTLS
|
||||
secure: false, // Upgrade to TLS via STARTTLS
|
||||
requireTLS: true, // Reject if STARTTLS is not available
|
||||
auth: {
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASS,
|
||||
@@ -76,4 +77,17 @@ export const emailService = {
|
||||
await sendEmail(to, 'Suscripción cancelada - Horux360', subscriptionCancelledEmail(data));
|
||||
await sendEmail(env.ADMIN_EMAIL, `Suscripción cancelada: ${data.nombre}`, subscriptionCancelledEmail(data));
|
||||
},
|
||||
|
||||
sendNewClientAdmin: async (data: {
|
||||
clienteNombre: string;
|
||||
clienteRfc: string;
|
||||
adminEmail: string;
|
||||
adminNombre: string;
|
||||
tempPassword: string;
|
||||
databaseName: string;
|
||||
plan: string;
|
||||
}) => {
|
||||
const { newClientAdminEmail } = await import('./templates/new-client-admin.js');
|
||||
await sendEmail(env.ADMIN_EMAIL, `Nuevo cliente: ${data.clienteNombre} (${data.clienteRfc})`, newClientAdminEmail(data));
|
||||
},
|
||||
};
|
||||
|
||||
68
apps/api/src/services/email/templates/new-client-admin.ts
Normal file
68
apps/api/src/services/email/templates/new-client-admin.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { baseTemplate } from './base.js';
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function newClientAdminEmail(data: {
|
||||
clienteNombre: string;
|
||||
clienteRfc: string;
|
||||
adminEmail: string;
|
||||
adminNombre: string;
|
||||
tempPassword: string;
|
||||
databaseName: string;
|
||||
plan: string;
|
||||
}): string {
|
||||
return baseTemplate(`
|
||||
<h2 style="color:#1e293b;margin:0 0 16px;">Nuevo Cliente Registrado</h2>
|
||||
<p style="color:#475569;line-height:1.6;margin:0 0 24px;">
|
||||
Se ha dado de alta un nuevo cliente en Horux360. A continuación los detalles:
|
||||
</p>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
|
||||
<tr>
|
||||
<td colspan="2" style="background-color:#1e293b;color:#ffffff;padding:12px 16px;font-weight:bold;border-radius:6px 6px 0 0;">
|
||||
Datos del Cliente
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;width:40%;">Empresa</td>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.clienteNombre)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">RFC</td>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.clienteRfc)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">Plan</td>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.plan)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
|
||||
<tr>
|
||||
<td colspan="2" style="background-color:#3b82f6;color:#ffffff;padding:12px 16px;font-weight:bold;border-radius:6px 6px 0 0;">
|
||||
Credenciales del Usuario
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;width:40%;">Nombre</td>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.adminNombre)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">Email</td>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.adminEmail)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">Contraseña temporal</td>
|
||||
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;">
|
||||
<code style="background-color:#f1f5f9;padding:4px 8px;border-radius:4px;font-size:14px;color:#dc2626;">${escapeHtml(data.tempPassword)}</code>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="color:#94a3b8;font-size:12px;margin:0;">
|
||||
Este correo contiene información confidencial. No lo reenvíes ni lo compartas.
|
||||
</p>
|
||||
`);
|
||||
}
|
||||
@@ -80,7 +80,10 @@ export function verifyWebhookSignature(
|
||||
xRequestId: string,
|
||||
dataId: string
|
||||
): boolean {
|
||||
if (!env.MP_WEBHOOK_SECRET) return true; // Skip in dev
|
||||
if (!env.MP_WEBHOOK_SECRET) {
|
||||
console.error('[WEBHOOK] MP_WEBHOOK_SECRET not configured - rejecting webhook');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse x-signature header: "ts=...,v1=..."
|
||||
const parts: Record<string, string> = {};
|
||||
|
||||
@@ -91,13 +91,24 @@ export async function createTenant(data: {
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Send welcome email (non-blocking)
|
||||
// 5. Send welcome email to client (non-blocking)
|
||||
emailService.sendWelcome(data.adminEmail, {
|
||||
nombre: data.adminNombre,
|
||||
email: data.adminEmail,
|
||||
tempPassword,
|
||||
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
|
||||
|
||||
// 6. Send new client notification to admin with DB credentials
|
||||
emailService.sendNewClientAdmin({
|
||||
clienteNombre: data.nombre,
|
||||
clienteRfc: data.rfc.toUpperCase(),
|
||||
adminEmail: data.adminEmail,
|
||||
adminNombre: data.adminNombre,
|
||||
tempPassword,
|
||||
databaseName,
|
||||
plan,
|
||||
}).catch(err => console.error('[EMAIL] New client admin email failed:', err));
|
||||
|
||||
return { tenant, user, tempPassword };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';
|
||||
|
||||
export async function getUsuarios(tenantId: string): Promise<UserListItem[]> {
|
||||
@@ -37,8 +38,8 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
|
||||
throw new Error('Límite de usuarios alcanzado para este plan');
|
||||
}
|
||||
|
||||
// Generate temporary password
|
||||
const tempPassword = Math.random().toString(36).slice(-8);
|
||||
// Generate cryptographically secure temporary password
|
||||
const tempPassword = randomBytes(4).toString('hex');
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
@@ -60,8 +61,7 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
|
||||
},
|
||||
});
|
||||
|
||||
// In production, send email with tempPassword
|
||||
console.log(`Temporary password for ${data.email}: ${tempPassword}`);
|
||||
// TODO: Send email with tempPassword to the invited user
|
||||
|
||||
return {
|
||||
...user,
|
||||
|
||||
31
apps/api/src/utils/global-admin.ts
Normal file
31
apps/api/src/utils/global-admin.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
const ADMIN_TENANT_RFC = 'CAS2408138W2';
|
||||
|
||||
// Cache: tenantId -> { rfc, expires }
|
||||
const rfcCache = new Map<string, { rfc: string; expires: number }>();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Checks if the given user belongs to the global admin tenant (CAS2408138W2).
|
||||
* Uses an in-memory cache to avoid repeated DB lookups.
|
||||
*/
|
||||
export async function isGlobalAdmin(tenantId: string, role: string): Promise<boolean> {
|
||||
if (role !== 'admin') return false;
|
||||
|
||||
const cached = rfcCache.get(tenantId);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.rfc === ADMIN_TENANT_RFC;
|
||||
}
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { rfc: true },
|
||||
});
|
||||
|
||||
if (tenant) {
|
||||
rfcCache.set(tenantId, { rfc: tenant.rfc, expires: Date.now() + CACHE_TTL });
|
||||
}
|
||||
|
||||
return tenant?.rfc === ADMIN_TENANT_RFC;
|
||||
}
|
||||
Reference in New Issue
Block a user