Feat: Ajustes de seguridad
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<HostOptions>(options =>
|
||||
{
|
||||
options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
|
||||
});
|
||||
|
||||
// DB CONTEXTS (Legacy unificado en eldia)
|
||||
builder.Services.AddDbContext<EldiaDbContext>(options =>
|
||||
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<IImageStorageService, ImageStorageService>();
|
||||
builder.Services.AddHostedService<AdExpirationService>();
|
||||
builder.Services.AddHostedService<TokenCleanupService>();
|
||||
|
||||
// 🔒 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<MotoresArgentinosV2.API.Middleware.ExceptionHandlingMiddleware>();
|
||||
|
||||
// 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:; " +
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -2,6 +2,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;
|
||||
index index.html index.htm;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,4 +8,7 @@ export default defineConfig({
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
build: {
|
||||
sourcemap: false, // Seguridad: Ocultar código fuente en producción
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user