Files
MotoresArgentinosV2/Backend/MotoresArgentinosV2.Infrastructure/Services/NotificationPreferenceService.cs

279 lines
10 KiB
C#

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<NotificationPreferenceService> _logger;
// Clave secreta para firmar los tokens de baja (HMAC-SHA256)
private readonly string _hmacSecret;
public NotificationPreferenceService(
MotoresV2DbContext context,
IConfiguration config,
ILogger<NotificationPreferenceService> 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.");
}
/// <inheritdoc/>
public async Task<NotificationPreferencesDto> 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<UserNotificationPreference> prefs, string category)
{
var pref = prefs.FirstOrDefault(p => p.Category == category);
// Sin registro = habilitado por defecto
return pref?.IsEnabled ?? true;
}
/// <inheritdoc/>
public async Task UpdatePreferencesAsync(int userId, UpdateNotificationPreferencesDto dto)
{
// Mapa categoría → valor del DTO
var updates = new Dictionary<string, bool>
{
[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);
}
/// <inheritdoc/>
public async Task<bool> 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;
}
/// <inheritdoc/>
public async Task<string> 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;
}
/// <inheritdoc/>
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 ────────────────────────────────────────
/// <summary>
/// Genera un token opaco: GUID aleatorio codificado en base64url + separador + HMAC-SHA256 del payload.
/// Formato: {guid_b64url}.{hmac_b64url}
/// </summary>
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}";
}
/// <summary>
/// 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.
/// </summary>
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
};
}