Feat: Ajustes de seguridad

This commit is contained in:
2026-01-30 11:18:56 -03:00
parent 32cf2ba74a
commit 8f6f8d4500
10 changed files with 179 additions and 207 deletions

View File

@@ -11,7 +11,7 @@ namespace MotoresArgentinosV2.API.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
// CORRECCIÓN: Se quitó [EnableRateLimiting("AuthPolicy")] de aquí para no bloquear /me ni /logout [EnableRateLimiting("AuthPolicy")]
public class AuthController : ControllerBase public class AuthController : ControllerBase
{ {
private readonly IIdentityService _identityService; private readonly IIdentityService _identityService;
@@ -33,7 +33,7 @@ public class AuthController : ControllerBase
var cookieOptions = new CookieOptions var cookieOptions = new CookieOptions
{ {
HttpOnly = true, // Seguridad: JS no puede leer esto HttpOnly = true, // Seguridad: JS no puede leer esto
Expires = DateTime.UtcNow.AddDays(7), Expires = DateTime.UtcNow.AddMinutes(15),
Secure = true, // Solo HTTPS (localhost con https cuenta) Secure = true, // Solo HTTPS (localhost con https cuenta)
SameSite = SameSiteMode.Strict, SameSite = SameSiteMode.Strict,
IsEssential = true IsEssential = true

View File

@@ -1,180 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Infrastructure.Data;
using MotoresArgentinosV2.Core.Entities;
using MotoresArgentinosV2.Core.Interfaces;
namespace MotoresArgentinosV2.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SeedController : ControllerBase
{
private readonly MotoresV2DbContext _context;
private readonly IPasswordService _passwordService;
public SeedController(MotoresV2DbContext context, IPasswordService passwordService)
{
_context = context;
_passwordService = passwordService;
}
[HttpPost("database")]
public async Task<IActionResult> SeedDatabase()
{
// 1. Asegurar Marcas y Modelos
if (!await _context.Brands.AnyAsync())
{
var toyota = new Brand { VehicleTypeID = 1, Name = "Toyota" };
var ford = new Brand { VehicleTypeID = 1, Name = "Ford" };
var vw = new Brand { VehicleTypeID = 1, Name = "Volkswagen" };
var honda = new Brand { VehicleTypeID = 2, Name = "Honda" };
var yamaha = new Brand { VehicleTypeID = 2, Name = "Yamaha" };
_context.Brands.AddRange(toyota, ford, vw, honda, yamaha);
await _context.SaveChangesAsync();
_context.Models.AddRange(
new Model { BrandID = toyota.BrandID, Name = "Corolla" },
new Model { BrandID = toyota.BrandID, Name = "Hilux" },
new Model { BrandID = ford.BrandID, Name = "Ranger" },
new Model { BrandID = vw.BrandID, Name = "Amarok" },
new Model { BrandID = honda.BrandID, Name = "Wave 110" },
new Model { BrandID = yamaha.BrandID, Name = "FZ FI" }
);
await _context.SaveChangesAsync();
}
// 2. Crear Usuarios de Prueba
var testUser = await _context.Users.FirstOrDefaultAsync(u => u.UserName == "testuser");
if (testUser == null)
{
testUser = new User
{
UserName = "testuser",
Email = "test@motores.com.ar",
PasswordHash = _passwordService.HashPassword("test123"),
FirstName = "Usuario",
LastName = "Prueba",
MigrationStatus = 1,
UserType = 1
};
_context.Users.Add(testUser);
}
var adminUser = await _context.Users.FirstOrDefaultAsync(u => u.UserName == "admin");
if (adminUser == null)
{
adminUser = new User
{
UserName = "admin",
Email = "admin@motoresargentinos.com.ar",
PasswordHash = _passwordService.HashPassword("admin123"),
FirstName = "Admin",
LastName = "Motores",
MigrationStatus = 1,
UserType = 3 // ADMIN
};
_context.Users.Add(adminUser);
}
await _context.SaveChangesAsync();
// 3. Crear Avisos de Prueba
if (!await _context.Ads.AnyAsync())
{
var brands = await _context.Brands.ToListAsync();
var models = await _context.Models.ToListAsync();
var ad1 = new Ad
{
UserID = testUser.UserID,
VehicleTypeID = 1,
BrandID = brands.First(b => b.Name == "Toyota").BrandID,
ModelID = models.First(m => m.Name == "Corolla").ModelID,
VersionName = "Toyota Corolla 1.8 XLI",
Year = 2022,
KM = 15000,
Price = 25000,
Currency = "USD",
Description = "Excelente estado, único dueño. Service al día.",
StatusID = 4, // Activo
IsFeatured = true,
CreatedAt = DateTime.UtcNow,
PublishedAt = DateTime.UtcNow
};
var ad2 = new Ad
{
UserID = testUser.UserID,
VehicleTypeID = 2,
BrandID = brands.First(b => b.Name == "Honda").BrandID,
ModelID = models.First(m => m.Name == "Wave 110").ModelID,
VersionName = "Honda Wave 110 S",
Year = 2023,
KM = 2500,
Price = 1800,
Currency = "USD",
Description = "Impecable, como nueva. Muy económica.",
StatusID = 4, // Activo
CreatedAt = DateTime.UtcNow,
PublishedAt = DateTime.UtcNow
};
var ad3 = new Ad
{
UserID = testUser.UserID,
VehicleTypeID = 1,
BrandID = brands.First(b => b.Name == "Ford").BrandID,
ModelID = models.First(m => m.Name == "Ranger").ModelID,
VersionName = "Ford Ranger Limited 4x4",
Year = 2021,
KM = 35000,
Price = 42000,
Currency = "USD",
Description = "Camioneta impecable, lista para transferir.",
StatusID = 3, // Moderacion Pendiente
CreatedAt = DateTime.UtcNow
};
_context.Ads.AddRange(ad1, ad2, ad3);
await _context.SaveChangesAsync();
// Agregar fotos
_context.AdPhotos.AddRange(
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1621007947382-bb3c3994e3fb?auto=format&fit=crop&q=80&w=1200", IsCover = true },
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1590362891991-f776e933a68e?auto=format&fit=crop&q=80&w=1200" },
new AdPhoto { AdID = ad1.AdID, FilePath = "https://images.unsplash.com/photo-1549317661-bd32c8ce0db2?auto=format&fit=crop&q=80&w=1200" },
new AdPhoto { AdID = ad2.AdID, FilePath = "https://images.unsplash.com/photo-1558981403-c5f91cbba527?auto=format&fit=crop&q=80&w=800", IsCover = true },
new AdPhoto { AdID = ad3.AdID, FilePath = "https://images.unsplash.com/photo-1533473359331-0135ef1b58bf?auto=format&fit=crop&q=80&w=1200", IsCover = true }
);
// Agregar Características Técnicas
_context.AdFeatures.AddRange(
new AdFeature { AdID = ad1.AdID, FeatureKey = "Combustible", FeatureValue = "Nafta" },
new AdFeature { AdID = ad1.AdID, FeatureKey = "Transmision", FeatureValue = "Automática" },
new AdFeature { AdID = ad1.AdID, FeatureKey = "Color", FeatureValue = "Blanco" },
new AdFeature { AdID = ad2.AdID, FeatureKey = "Combustible", FeatureValue = "Nafta" },
new AdFeature { AdID = ad2.AdID, FeatureKey = "Color", FeatureValue = "Rojo" }
);
await _context.SaveChangesAsync();
}
return Ok("Database seeded successfully with Features and Multiple Photos");
}
[HttpPost("reset")]
public async Task<IActionResult> ResetDatabase()
{
_context.ChangeTracker.Clear();
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Transactions");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM AdPhotos");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM AdFeatures");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Ads");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Models");
await _context.Database.ExecuteSqlRawAsync("DELETE FROM Brands");
return await SeedDatabase();
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Text.Json;
namespace MotoresArgentinosV2.API.Middleware;
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger, IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
// Loguear el error real con stack trace completo
_logger.LogError(exception, "Error no controlado procesando la solicitud: {Method} {Path}", context.Request.Method, context.Request.Path);
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var response = new
{
status = context.Response.StatusCode,
message = "Ocurrió un error interno en el servidor. Por favor, intente nuevamente más tarde.",
// En desarrollo mostramos el detalle, en producción ocultamos todo
detail = _env.IsDevelopment() ? exception.Message : null
};
var json = JsonSerializer.Serialize(response);
await context.Response.WriteAsync(json);
}
}

View File

@@ -78,25 +78,28 @@ builder.Services.AddRateLimiter(options =>
options.AddPolicy("AuthPolicy", context => options.AddPolicy("AuthPolicy", context =>
{ {
// Si es localhost, SIN LÍMITES // Si es localhost, SIN LÍMITES (Evita auto-bloqueo en desarrollo)
var remoteIp = context.Connection.RemoteIpAddress; var remoteIp = context.Connection.RemoteIpAddress;
if (System.Net.IPAddress.IsLoopback(remoteIp!)) if (remoteIp != null && System.Net.IPAddress.IsLoopback(remoteIp))
{ {
return RateLimitPartition.GetNoLimiter("loopback_auth"); return RateLimitPartition.GetNoLimiter("loopback_auth");
} }
return RateLimitPartition.GetFixedWindowLimiter( return RateLimitPartition.GetFixedWindowLimiter("auth_limit", _ => new FixedWindowRateLimiterOptions
partitionKey: remoteIp?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{ {
AutoReplenishment = true, PermitLimit = 5,
PermitLimit = 5, // 5 intentos por minuto para IPs externas Window = TimeSpan.FromMinutes(1),
QueueLimit = 0, QueueLimit = 0
Window = TimeSpan.FromMinutes(1)
}); });
}); });
}); });
// 🛡️ SEGURIDAD: Evitar que el host se caiga si un servicio de fondo falla
builder.Services.Configure<HostOptions>(options =>
{
options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
});
// DB CONTEXTS (Legacy unificado en eldia) // DB CONTEXTS (Legacy unificado en eldia)
builder.Services.AddDbContext<EldiaDbContext>(options => builder.Services.AddDbContext<EldiaDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("eldia"))); options.UseSqlServer(builder.Configuration.GetConnectionString("eldia")));
@@ -119,6 +122,7 @@ builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("SmtpS
builder.Services.AddScoped<IEmailService, SmtpEmailService>(); builder.Services.AddScoped<IEmailService, SmtpEmailService>();
builder.Services.AddScoped<IImageStorageService, ImageStorageService>(); builder.Services.AddScoped<IImageStorageService, ImageStorageService>();
builder.Services.AddHostedService<AdExpirationService>(); builder.Services.AddHostedService<AdExpirationService>();
builder.Services.AddHostedService<TokenCleanupService>();
// 🔒 JWT AUTH // 🔒 JWT AUTH
var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key Missing"); var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key Missing");
@@ -158,8 +162,10 @@ builder.Services.AddSwaggerGen();
var app = builder.Build(); var app = builder.Build();
// USAR EL MIDDLEWARE AL PRINCIPIO // Middleware de Manejo Global de Excepciones (Debe ser el primero para atrapar todo)
// Debe ser lo primero para que el RateLimiter y los Logs vean la IP real app.UseMiddleware<MotoresArgentinosV2.API.Middleware.ExceptionHandlingMiddleware>();
// USAR EL MIDDLEWARE DE HEADERS
app.UseForwardedHeaders(); app.UseForwardedHeaders();
// 🔒 HEADERS DE SEGURIDAD MIDDLEWARE // 🔒 HEADERS DE SEGURIDAD MIDDLEWARE
@@ -170,6 +176,9 @@ app.Use(async (context, next) =>
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block"); context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
// Permissions-Policy: Bloquear funcionalidades sensibles del navegador no usadas
context.Response.Headers.Append("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()");
// CSP adaptada para permitir pagos en Payway y WebSockets de Vite // CSP adaptada para permitir pagos en Payway y WebSockets de Vite
string csp = "default-src 'self'; " + string csp = "default-src 'self'; " +
"img-src 'self' data: https: blob:; " + "img-src 'self' data: https: blob:; " +

View File

@@ -43,14 +43,19 @@ public class AdExpirationService : BackgroundService
await ProcessWeeklyStatsAsync(); await ProcessWeeklyStatsAsync();
await ProcessPaymentRemindersAsync(); await ProcessPaymentRemindersAsync();
await ProcessUnreadMessagesRemindersAsync(); await ProcessUnreadMessagesRemindersAsync();
// Ejecutar cada 1 hora
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
catch (OperationCanceledException)
{
break;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error CRÍTICO en ciclo de mantenimiento."); _logger.LogError(ex, "Error CRÍTICO en ciclo de mantenimiento.");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
} }
// Ejecutar cada 1 hora
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
} }
} }

View File

@@ -0,0 +1,72 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using MotoresArgentinosV2.Infrastructure.Data;
namespace MotoresArgentinosV2.Infrastructure.Services;
public class TokenCleanupService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TokenCleanupService> _logger;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(24); // Ejecutar cada 24hs
public TokenCleanupService(IServiceProvider serviceProvider, ILogger<TokenCleanupService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Servicio de Limpieza de Tokens iniciado.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await CleanExpiredTokensAsync(stoppingToken);
// Esperar hasta la próxima ejecución
await Task.Delay(_cleanupInterval, stoppingToken);
}
catch (OperationCanceledException)
{
// El servicio se está deteniendo, es normal
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error durante la limpieza de tokens.");
// Si hay un error, esperamos un poco antes de reintentar (evitar bucle infinito de errores)
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
private async Task CleanExpiredTokensAsync(CancellationToken stoppingToken)
{
using (var scope = _serviceProvider.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<MotoresV2DbContext>();
// Definir criterios de limpieza
var cutoffDate = DateTime.UtcNow; // Tokens ya expirados
var revokedCutoff = DateTime.UtcNow.AddDays(-30); // Tokens revocados hace más de 30 días (auditoría)
_logger.LogInformation("Ejecutando limpieza de RefreshTokens expirados o antiguos...");
// Opción 1: Borrado con SQL Raw para eficiencia en lotes grandes
// Asumimos que la tabla se llama 'RefreshTokens' en la DB
var rowsAffected = await context.Database.ExecuteSqlRawAsync(
"DELETE FROM [RefreshTokens] WHERE [Expires] < @cutoffDate OR ([Revoked] IS NOT NULL AND [Revoked] < @revokedCutoff)",
new[] {
new Microsoft.Data.SqlClient.SqlParameter("@cutoffDate", cutoffDate),
new Microsoft.Data.SqlClient.SqlParameter("@revokedCutoff", revokedCutoff)
},
stoppingToken);
_logger.LogInformation("Limpieza completada. {Count} tokens eliminados.", rowsAffected);
}
}
}

View File

@@ -33,7 +33,7 @@ public class TokenService : ITokenService
new Claim(ClaimTypes.Email, user.Email), new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Role, user.UserType == 3 ? "Admin" : "User") new Claim(ClaimTypes.Role, user.UserType == 3 ? "Admin" : "User")
}), }),
Expires = DateTime.UtcNow.AddHours(8), Expires = DateTime.UtcNow.AddMinutes(15),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature), SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
Issuer = _config["Jwt:Issuer"], Issuer = _config["Jwt:Issuer"],
Audience = _config["Jwt:Audience"] Audience = _config["Jwt:Audience"]

View File

@@ -2,6 +2,9 @@ server {
listen 80; listen 80;
server_name localhost; server_name localhost;
# Seguridad: Limitar tamaño de subida para prevenir DoS
client_max_body_size 20M;
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;

View File

@@ -8,6 +8,8 @@
--animate-fade-in-up: fade-in-up 0.5s ease-out; --animate-fade-in-up: fade-in-up 0.5s ease-out;
--animate-glow: glow 2s infinite alternate; --animate-glow: glow 2s infinite alternate;
--animate-fade-in: fade-in 0.3s ease-out forwards;
--animate-scale-up: scale-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
@keyframes fade-in-up { @keyframes fade-in-up {
from { from {
@@ -52,16 +54,22 @@
transform: scale(1); transform: scale(1);
} }
} }
}
/* Clases de utilidad personalizadas (fuera de @theme) */
.animate-fade-in { .animate-fade-in {
animation: fade-in 0.3s ease-out forwards; animation: var(--animate-fade-in);
} }
.animate-scale-up { .animate-scale-up {
animation: scale-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; animation: var(--animate-scale-up);
} }
.animate-fade-in-up {
animation: var(--animate-fade-in-up);
} }
:root { :root {
background-color: var(--color-dark-bg); background-color: var(--color-dark-bg);
color: white; color: white;

View File

@@ -8,4 +8,7 @@ export default defineConfig({
react(), react(),
tailwindcss(), tailwindcss(),
], ],
build: {
sourcemap: false, // Seguridad: Ocultar código fuente en producción
},
}) })