using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using MotoresArgentinosV2.Core.DTOs; using MotoresArgentinosV2.Core.Entities; using MotoresArgentinosV2.Core.Interfaces; using MotoresArgentinosV2.Infrastructure.Data; namespace MotoresArgentinosV2.Infrastructure.Services; public class NotificationPreferenceService : INotificationPreferenceService { private readonly MotoresV2DbContext _context; private readonly IConfiguration _config; private readonly ILogger _logger; // Clave secreta para firmar los tokens de baja (HMAC-SHA256) private readonly string _hmacSecret; public NotificationPreferenceService( MotoresV2DbContext context, IConfiguration config, ILogger logger) { _context = context; _config = config; _logger = logger; // La clave se lee de la configuración; si no existe, fallback a la clave JWT (nunca null en producción) _hmacSecret = config["Unsubscribe:HmacSecret"] ?? config["Jwt:Key"] ?? throw new InvalidOperationException("Falta clave HMAC para tokens de baja."); } /// public async Task GetPreferencesAsync(int userId) { // Cargamos todas las preferencias guardadas del usuario var prefs = await _context.NotificationPreferences .Where(p => p.UserID == userId) .ToListAsync(); // Si no hay registro, la preferencia es TRUE (habilitada) por defecto return new NotificationPreferencesDto { Sistema = GetIsEnabled(prefs, NotificationCategory.Sistema), Marketing = GetIsEnabled(prefs, NotificationCategory.Marketing), Rendimiento = GetIsEnabled(prefs, NotificationCategory.Rendimiento), Mensajes = GetIsEnabled(prefs, NotificationCategory.Mensajes), }; } private static bool GetIsEnabled(List prefs, string category) { var pref = prefs.FirstOrDefault(p => p.Category == category); // Sin registro = habilitado por defecto return pref?.IsEnabled ?? true; } /// public async Task UpdatePreferencesAsync(int userId, UpdateNotificationPreferencesDto dto) { // Mapa categoría → valor del DTO var updates = new Dictionary { [NotificationCategory.Sistema] = dto.Sistema, [NotificationCategory.Marketing] = dto.Marketing, [NotificationCategory.Rendimiento] = dto.Rendimiento, [NotificationCategory.Mensajes] = dto.Mensajes, }; foreach (var (category, isEnabled) in updates) { var existing = await _context.NotificationPreferences .FirstOrDefaultAsync(p => p.UserID == userId && p.Category == category); if (existing == null) { // Solo creamos el registro si el usuario está DESACTIVANDO (optimización) // Si es true y no hay registro, es el valor por defecto → no necesitamos guardar nada if (!isEnabled) { _context.NotificationPreferences.Add(new UserNotificationPreference { UserID = userId, Category = category, IsEnabled = false, UpdatedAt = DateTime.UtcNow }); } } else { existing.IsEnabled = isEnabled; existing.UpdatedAt = DateTime.UtcNow; } } await _context.SaveChangesAsync(); _logger.LogInformation("Preferencias de notificación actualizadas para UserID {UserId}", userId); } /// public async Task IsEnabledAsync(int userId, string category) { var pref = await _context.NotificationPreferences .FirstOrDefaultAsync(p => p.UserID == userId && p.Category == category); // Sin registro = habilitado por defecto return pref?.IsEnabled ?? true; } /// public async Task GetOrCreateUnsubscribeTokenAsync(int userId, string category) { // Reutilizamos un token existente y vigente si lo hay var existing = await _context.UnsubscribeTokens .FirstOrDefaultAsync(t => t.UserID == userId && t.Category == category && !t.IsUsed && t.ExpiresAt > DateTime.UtcNow); if (existing != null) return existing.Token; // Generamos un nuevo token seguro: GUID aleatorio + firma HMAC var rawToken = GenerateSignedToken(userId, category); _context.UnsubscribeTokens.Add(new UnsubscribeToken { UserID = userId, Category = category, Token = rawToken, CreatedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddDays(365), IsUsed = false }); await _context.SaveChangesAsync(); return rawToken; } /// public async Task<(bool Success, string CategoryLabel)> UnsubscribeAsync(string token) { if (string.IsNullOrWhiteSpace(token)) return (false, string.Empty); // Buscamos el token en la base de datos var record = await _context.UnsubscribeTokens .Include(t => t.User) .FirstOrDefaultAsync(t => t.Token == token); if (record == null) { _logger.LogWarning("Intento de baja con token inexistente."); return (false, string.Empty); } if (record.IsUsed) { // El token ya fue usado previamente; la baja ya está aplicada return (true, GetCategoryLabel(record.Category)); } if (record.ExpiresAt < DateTime.UtcNow) { _logger.LogWarning("Intento de baja con token expirado. UserID={UserId}", record.UserID); return (false, string.Empty); } // Verificamos la firma del token para garantizar integridad if (!VerifySignedToken(record.Token, record.UserID, record.Category)) { _logger.LogWarning("Token de baja con firma inválida. UserID={UserId}", record.UserID); return (false, string.Empty); } // Actualizamos la preferencia → deshabilitar categoría var pref = await _context.NotificationPreferences .FirstOrDefaultAsync(p => p.UserID == record.UserID && p.Category == record.Category); if (pref == null) { _context.NotificationPreferences.Add(new UserNotificationPreference { UserID = record.UserID, Category = record.Category, IsEnabled = false, UpdatedAt = DateTime.UtcNow }); } else { pref.IsEnabled = false; pref.UpdatedAt = DateTime.UtcNow; } // Marcamos el token como usado (one-click = un solo uso) record.IsUsed = true; await _context.SaveChangesAsync(); _logger.LogInformation( "Baja procesada correctamente. UserID={UserId}, Category={Category}", record.UserID, record.Category); return (true, GetCategoryLabel(record.Category)); } // ─── Métodos privados de seguridad ──────────────────────────────────────── /// /// Genera un token opaco: GUID aleatorio codificado en base64url + separador + HMAC-SHA256 del payload. /// Formato: {guid_b64url}.{hmac_b64url} /// private string GenerateSignedToken(int userId, string category) { // Parte aleatoria para garantizar unicidad var randomPart = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) .Replace("+", "-").Replace("/", "_").TrimEnd('='); // Payload a firmar var payload = $"{userId}:{category}:{randomPart}"; var signature = ComputeHmac(payload); return $"{randomPart}.{signature}"; } /// /// Verifica que el token almacenado corresponde realmente a userId + category /// recalculando el HMAC y comparando de forma segura. /// NOTA: el token en la DB ya nos garantiza la asociación UserID/Category; /// aquí adicionalmente verificamos que nadie manipuló la columna. /// private bool VerifySignedToken(string token, int userId, string category) { // El token tiene formato: {randomPart}.{signature} var dot = token.LastIndexOf('.'); if (dot < 0) return false; var randomPart = token[..dot]; var storedSig = token[(dot + 1)..]; var payload = $"{userId}:{category}:{randomPart}"; var expected = ComputeHmac(payload); // CryptographicOperations.FixedTimeEquals protege contra timing attacks return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(storedSig), Encoding.UTF8.GetBytes(expected)); } private string ComputeHmac(string payload) { var keyBytes = Encoding.UTF8.GetBytes(_hmacSecret); var dataBytes = Encoding.UTF8.GetBytes(payload); using var hmac = new HMACSHA256(keyBytes); var hash = hmac.ComputeHash(dataBytes); return Convert.ToBase64String(hash) .Replace("+", "-").Replace("/", "_").TrimEnd('='); } private static string GetCategoryLabel(string category) => category switch { NotificationCategory.Sistema => "Avisos del Sistema", NotificationCategory.Marketing => "Promociones y Marketing", NotificationCategory.Rendimiento => "Resumen de Rendimiento", NotificationCategory.Mensajes => "Recordatorio de Mensajes", _ => category }; }