279 lines
10 KiB
C#
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
|
|
};
|
|
}
|