Sistema de Notificaciones y Baja One-Click
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user