diff --git a/Backend/MotoresArgentinosV2.API/Controllers/AuthController.cs b/Backend/MotoresArgentinosV2.API/Controllers/AuthController.cs index f4b34f7..40bc104 100644 --- a/Backend/MotoresArgentinosV2.API/Controllers/AuthController.cs +++ b/Backend/MotoresArgentinosV2.API/Controllers/AuthController.cs @@ -11,7 +11,7 @@ namespace MotoresArgentinosV2.API.Controllers; [ApiController] [Route("api/[controller]")] -// CORRECCIÓN: Se quitó [EnableRateLimiting("AuthPolicy")] de aquí para no bloquear /me ni /logout +[EnableRateLimiting("AuthPolicy")] public class AuthController : ControllerBase { private readonly IIdentityService _identityService; @@ -33,7 +33,7 @@ public class AuthController : ControllerBase var cookieOptions = new CookieOptions { 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) SameSite = SameSiteMode.Strict, IsEssential = true diff --git a/Backend/MotoresArgentinosV2.API/Controllers/SeedController.cs b/Backend/MotoresArgentinosV2.API/Controllers/SeedController.cs deleted file mode 100644 index b8ee48b..0000000 --- a/Backend/MotoresArgentinosV2.API/Controllers/SeedController.cs +++ /dev/null @@ -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 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 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(); - } -} diff --git a/Backend/MotoresArgentinosV2.API/Middleware/ExceptionHandlingMiddleware.cs b/Backend/MotoresArgentinosV2.API/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..e45e545 --- /dev/null +++ b/Backend/MotoresArgentinosV2.API/Middleware/ExceptionHandlingMiddleware.cs @@ -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 _logger; + private readonly IHostEnvironment _env; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger 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); + } +} diff --git a/Backend/MotoresArgentinosV2.API/Program.cs b/Backend/MotoresArgentinosV2.API/Program.cs index b941546..453ff19 100644 --- a/Backend/MotoresArgentinosV2.API/Program.cs +++ b/Backend/MotoresArgentinosV2.API/Program.cs @@ -78,25 +78,28 @@ builder.Services.AddRateLimiter(options => 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; - if (System.Net.IPAddress.IsLoopback(remoteIp!)) + if (remoteIp != null && System.Net.IPAddress.IsLoopback(remoteIp)) { return RateLimitPartition.GetNoLimiter("loopback_auth"); } - return RateLimitPartition.GetFixedWindowLimiter( - partitionKey: remoteIp?.ToString() ?? "unknown", - factory: _ => new FixedWindowRateLimiterOptions - { - AutoReplenishment = true, - PermitLimit = 5, // 5 intentos por minuto para IPs externas - QueueLimit = 0, - Window = TimeSpan.FromMinutes(1) - }); + return RateLimitPartition.GetFixedWindowLimiter("auth_limit", _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + }); }); }); +// 🛡️ SEGURIDAD: Evitar que el host se caiga si un servicio de fondo falla +builder.Services.Configure(options => +{ + options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore; +}); + // DB CONTEXTS (Legacy unificado en eldia) builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("eldia"))); @@ -119,6 +122,7 @@ builder.Services.Configure(builder.Configuration.GetSection("SmtpS builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); // 🔒 JWT AUTH var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key Missing"); @@ -158,8 +162,10 @@ builder.Services.AddSwaggerGen(); var app = builder.Build(); -// USAR EL MIDDLEWARE AL PRINCIPIO -// Debe ser lo primero para que el RateLimiter y los Logs vean la IP real +// Middleware de Manejo Global de Excepciones (Debe ser el primero para atrapar todo) +app.UseMiddleware(); + +// USAR EL MIDDLEWARE DE HEADERS app.UseForwardedHeaders(); // 🔒 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("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 string csp = "default-src 'self'; " + "img-src 'self' data: https: blob:; " + diff --git a/Backend/MotoresArgentinosV2.Infrastructure/Services/AdExpirationService.cs b/Backend/MotoresArgentinosV2.Infrastructure/Services/AdExpirationService.cs index 235c714..9aba90b 100644 --- a/Backend/MotoresArgentinosV2.Infrastructure/Services/AdExpirationService.cs +++ b/Backend/MotoresArgentinosV2.Infrastructure/Services/AdExpirationService.cs @@ -43,14 +43,19 @@ public class AdExpirationService : BackgroundService await ProcessWeeklyStatsAsync(); await ProcessPaymentRemindersAsync(); await ProcessUnreadMessagesRemindersAsync(); + + // Ejecutar cada 1 hora + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + } + catch (OperationCanceledException) + { + break; } catch (Exception ex) { _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); } } diff --git a/Backend/MotoresArgentinosV2.Infrastructure/Services/TokenCleanupService.cs b/Backend/MotoresArgentinosV2.Infrastructure/Services/TokenCleanupService.cs new file mode 100644 index 0000000..a42d675 --- /dev/null +++ b/Backend/MotoresArgentinosV2.Infrastructure/Services/TokenCleanupService.cs @@ -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 _logger; + private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(24); // Ejecutar cada 24hs + + public TokenCleanupService(IServiceProvider serviceProvider, ILogger 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(); + + // 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); + } + } +} diff --git a/Backend/MotoresArgentinosV2.Infrastructure/Services/TokenService.cs b/Backend/MotoresArgentinosV2.Infrastructure/Services/TokenService.cs index 374d65c..0d46e53 100644 --- a/Backend/MotoresArgentinosV2.Infrastructure/Services/TokenService.cs +++ b/Backend/MotoresArgentinosV2.Infrastructure/Services/TokenService.cs @@ -33,7 +33,7 @@ public class TokenService : ITokenService new Claim(ClaimTypes.Email, user.Email), 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), Issuer = _config["Jwt:Issuer"], Audience = _config["Jwt:Audience"] diff --git a/Frontend/nginx.conf b/Frontend/nginx.conf index 4744cab..6a90ae7 100644 --- a/Frontend/nginx.conf +++ b/Frontend/nginx.conf @@ -1,6 +1,9 @@ server { listen 80; server_name localhost; + + # Seguridad: Limitar tamaño de subida para prevenir DoS + client_max_body_size 20M; location / { root /usr/share/nginx/html; diff --git a/Frontend/src/index.css b/Frontend/src/index.css index bb7a6b9..2d91f3c 100644 --- a/Frontend/src/index.css +++ b/Frontend/src/index.css @@ -8,6 +8,8 @@ --animate-fade-in-up: fade-in-up 0.5s ease-out; --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 { from { @@ -52,16 +54,22 @@ transform: scale(1); } } - - .animate-fade-in { - animation: fade-in 0.3s ease-out forwards; - } - - .animate-scale-up { - animation: scale-up 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; - } } +/* Clases de utilidad personalizadas (fuera de @theme) */ +.animate-fade-in { + animation: var(--animate-fade-in); +} + +.animate-scale-up { + animation: var(--animate-scale-up); +} + +.animate-fade-in-up { + animation: var(--animate-fade-in-up); +} + + :root { background-color: var(--color-dark-bg); color: white; diff --git a/Frontend/vite.config.ts b/Frontend/vite.config.ts index 3d15f68..6ce20f0 100644 --- a/Frontend/vite.config.ts +++ b/Frontend/vite.config.ts @@ -8,4 +8,7 @@ export default defineConfig({ react(), tailwindcss(), ], + build: { + sourcemap: false, // Seguridad: Ocultar código fuente en producción + }, })