Feat: Seguridad avanzada para cambio de email y gestión de MFA

- Backend: Implementada lógica de tokens para cambio de mail y desactivación de 2FA.
- Frontend: Nuevos flujos de verificación en Perfil y Panel de Seguridad.
This commit is contained in:
2026-02-12 15:24:32 -03:00
parent 8c8c49894a
commit e096ed1590
10 changed files with 891 additions and 169 deletions

View File

@@ -215,7 +215,7 @@ public class AuthController : ControllerBase
// Permite a un usuario logueado iniciar el proceso de MFA voluntariamente
[Authorize]
[HttpPost("init-mfa")]
public async Task<IActionResult> InitMFA()
public async Task<IActionResult> InitMFA([FromBody] ConfirmSecurityActionRequest request)
{
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
@@ -223,13 +223,22 @@ public class AuthController : ControllerBase
var user = await _context.Users.FindAsync(int.Parse(userIdStr));
if (user == null) return NotFound();
// Generar secreto si no tiene
if (string.IsNullOrEmpty(user.MFASecret))
// Si el MFA ya está activo, exigimos el token de seguridad
if (user.IsMFAEnabled)
{
user.MFASecret = _tokenService.GenerateBase32Secret();
await _context.SaveChangesAsync();
if (user.SecurityActionToken != request.Token || user.SecurityActionTokenExpiresAt < DateTime.UtcNow)
{
return BadRequest(new { message = "El código de seguridad es inválido o ha expirado." });
}
// Limpiamos el token una vez usado
user.SecurityActionToken = null;
user.SecurityActionTokenExpiresAt = null;
}
// Generar secreto si no tiene (o si se está reconfigurando)
user.MFASecret = _tokenService.GenerateBase32Secret();
await _context.SaveChangesAsync();
// 📝 AUDITORÍA
_context.AuditLogs.Add(new AuditLog
{
@@ -243,11 +252,7 @@ public class AuthController : ControllerBase
var qrUri = _tokenService.GetQrCodeUri(user.Email, user.MFASecret);
return Ok(new
{
qrUri = qrUri,
secret = user.MFASecret
});
return Ok(new { qrUri, secret = user.MFASecret });
}
[HttpPost("verify-mfa")]
@@ -312,33 +317,58 @@ public class AuthController : ControllerBase
});
}
// CAMBIO DE EMAIL
[Authorize]
[HttpPost("disable-mfa")]
public async Task<IActionResult> DisableMFA()
[HttpPost("initiate-email-change")]
public async Task<IActionResult> InitiateEmailChange([FromBody] InitiateEmailChangeRequest request)
{
var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdStr)) return Unauthorized();
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
var (success, message) = await _identityService.InitiateEmailChangeAsync(userId, request.NewEmail, request.MfaCode);
var user = await _context.Users.FindAsync(int.Parse(userIdStr));
if (user == null) return NotFound();
if (!success) return BadRequest(new { message });
return Ok(new { message });
}
// Desactivamos MFA y limpiamos el secreto por seguridad
user.IsMFAEnabled = false;
user.MFASecret = null;
[HttpPost("confirm-email-change")]
public async Task<IActionResult> ConfirmEmailChange([FromBody] ConfirmEmailChangeRequest request)
{
var (success, message) = await _identityService.ConfirmEmailChangeAsync(request.Token);
if (!success) return BadRequest(new { message });
return Ok(new { message });
}
// 📝 AUDITORÍA
_context.AuditLogs.Add(new AuditLog
{
Action = "MFA_DISABLED",
Entity = "User",
EntityID = user.UserID,
UserID = user.UserID,
Details = "Autenticación de dos factores desactivada por el usuario."
});
// DESACTIVAR MFA
[Authorize]
[HttpPost("initiate-mfa-disable")]
public async Task<IActionResult> InitiateMFADisable()
{
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
var (success, message) = await _identityService.InitiateMFADisableAsync(userId);
await _context.SaveChangesAsync();
if (!success) return BadRequest(new { message });
return Ok(new { message });
}
return Ok(new { message = "MFA Desactivado correctamente." });
[Authorize]
[HttpPost("confirm-mfa-disable")]
public async Task<IActionResult> ConfirmMFADisable([FromBody] ConfirmSecurityActionRequest request)
{
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
var (success, message) = await _identityService.ConfirmMFADisableAsync(userId, request.Token);
if (!success) return BadRequest(new { message });
return Ok(new { message });
}
[Authorize]
[HttpPost("initiate-mfa-reconfigure")]
public async Task<IActionResult> InitiateMFAReconfigure()
{
var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0");
var (success, message) = await _identityService.InitiateMFAReconfigureAsync(userId);
if (!success) return BadRequest(new { message });
return Ok(new { message });
}
[HttpPost("migrate-password")]

View File

@@ -0,0 +1,17 @@
namespace MotoresArgentinosV2.Core.DTOs;
public class InitiateEmailChangeRequest
{
public string NewEmail { get; set; } = string.Empty;
public string MfaCode { get; set; } = string.Empty; // Código de Google Authenticator
}
public class ConfirmEmailChangeRequest
{
public string Token { get; set; } = string.Empty;
}
public class ConfirmSecurityActionRequest
{
public string Token { get; set; } = string.Empty; // Código numérico enviado por mail
}

View File

@@ -61,6 +61,15 @@ public class User
public DateTime? PasswordResetTokenExpiresAt { get; set; }
public DateTime? LastPasswordResetEmailSentAt { get; set; }
// Para cambio de email
public string? NewEmailCandidate { get; set; }
public string? EmailChangeToken { get; set; }
public DateTime? EmailChangeTokenExpiresAt { get; set; }
// Para reset/desactivación de MFA
public string? SecurityActionToken { get; set; }
public DateTime? SecurityActionTokenExpiresAt { get; set; }
// Bloqueo de usuario
public bool IsBlocked { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

View File

@@ -16,5 +16,13 @@ public interface IIdentityService
Task<(bool Success, string Message)> ForgotPasswordAsync(string email);
Task<(bool Success, string Message)> ResetPasswordAsync(string token, string newPassword);
Task<(bool Success, string Message)> ChangePasswordAsync(int userId, string current, string newPwd);
// Cambio de Email Seguro
Task<(bool Success, string Message)> InitiateEmailChangeAsync(int userId, string newEmail, string mfaCode);
Task<(bool Success, string Message)> ConfirmEmailChangeAsync(string token);
// Gestión MFA Segura
Task<(bool Success, string Message)> InitiateMFADisableAsync(int userId);
Task<(bool Success, string Message)> ConfirmMFADisableAsync(int userId, string token);
Task<(bool Success, string Message)> InitiateMFAReconfigureAsync(int userId);
Task<User> CreateGhostUserAsync(string email, string firstName, string lastName, string phone);
}

View File

@@ -15,16 +15,19 @@ public class IdentityService : IIdentityService
private readonly IPasswordService _passwordService;
private readonly IEmailService _emailService;
private readonly IConfiguration _config;
private readonly ITokenService _tokenService;
public IdentityService(
MotoresV2DbContext v2Context,
IPasswordService passwordService,
IEmailService emailService,
ITokenService tokenService,
IConfiguration config)
{
_v2Context = v2Context;
_passwordService = passwordService;
_emailService = emailService;
_tokenService = tokenService;
_config = config;
}
@@ -333,4 +336,187 @@ public class IdentityService : IIdentityService
await _v2Context.SaveChangesAsync();
return user;
}
// 1. INICIAR CAMBIO DE EMAIL (Requiere MFA activo y código válido)
public async Task<(bool Success, string Message)> InitiateEmailChangeAsync(int userId, string newEmail, string mfaCode)
{
var user = await _v2Context.Users.FindAsync(userId);
if (user == null) return (false, "Usuario no encontrado.");
// Validar formato básico de email
if (!new System.ComponentModel.DataAnnotations.EmailAddressAttribute().IsValid(newEmail))
return (false, "El formato del correo electrónico no es válido.");
// REGLA DE ORO: Solo si tiene MFA activo
if (!user.IsMFAEnabled || string.IsNullOrEmpty(user.MFASecret))
return (false, "Por seguridad, debes tener la Autenticación de Dos Factores (2FA) activada para cambiar tu email.");
// Validar código TOTP (Google Authenticator)
bool isCodeValid = _tokenService.ValidateTOTP(user.MFASecret, mfaCode);
if (!isCodeValid)
return (false, "El código de autenticación (2FA) es incorrecto.");
// Verificar que el mail no esté usado por otro
bool emailExists = await _v2Context.Users.AnyAsync(u => u.Email == newEmail);
if (emailExists)
return (false, "El nuevo correo electrónico ya está registrado en otra cuenta.");
// Generar Token
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
user.NewEmailCandidate = newEmail;
user.EmailChangeToken = token;
user.EmailChangeTokenExpiresAt = DateTime.UtcNow.AddMinutes(30);
await _v2Context.SaveChangesAsync();
// Enviar Email al NUEVO correo
var frontendUrl = _config["AppSettings:FrontendUrl"]?.Split(',')[0] ?? "http://localhost:5173";
var link = $"{frontendUrl}/confirmar-cambio-email?token={token}";
var body = $@"
<div style='font-family: Arial, sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
<h2 style='color: #0051ff;'>Confirma tu nuevo correo</h2>
<p>Hemos recibido una solicitud para transferir tu cuenta de Motores Argentinos a esta dirección.</p>
<p>Si fuiste tú, haz clic en el siguiente enlace para confirmar el cambio:</p>
<div style='margin: 25px 0;'>
<a href='{link}' style='background-color: #0051ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;'>CONFIRMAR EMAIL</a>
</div>
<p style='font-size: 12px; color: #666;'>El enlace expira en 30 minutos.</p>
</div>";
try
{
await _emailService.SendEmailAsync(newEmail, "Confirmar cambio de correo", body);
}
catch
{
return (false, "No se pudo enviar el correo de confirmación. Verifica que la dirección sea correcta.");
}
return (true, "Se ha enviado un enlace de confirmación a tu NUEVO correo electrónico. Revísalo para finalizar.");
}
// 2. CONFIRMAR CAMBIO (Click en el link)
public async Task<(bool Success, string Message)> ConfirmEmailChangeAsync(string token)
{
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.EmailChangeToken == token);
if (user == null) return (false, "El enlace es inválido o ya fue utilizado.");
if (user.EmailChangeTokenExpiresAt < DateTime.UtcNow) return (false, "El enlace ha expirado. Debes iniciar el trámite nuevamente.");
string oldEmail = user.Email;
// Aplicar cambio
user.Email = user.NewEmailCandidate!;
user.UserName = user.NewEmailCandidate!; // Opcional: Si usas el email como username
// Limpiar
user.NewEmailCandidate = null;
user.EmailChangeToken = null;
user.EmailChangeTokenExpiresAt = null;
// Auditoría
_v2Context.AuditLogs.Add(new AuditLog
{
Action = "SECURITY_EMAIL_CHANGED",
Entity = "User",
EntityID = user.UserID,
UserID = user.UserID,
Details = $"Email principal cambiado de {oldEmail} a {user.Email}"
});
await _v2Context.SaveChangesAsync();
return (true, "Tu correo electrónico ha sido actualizado correctamente. Por favor inicia sesión con tu nuevo email.");
}
// 3. INICIAR DESACTIVACIÓN MFA (Envía código al mail actual)
public async Task<(bool Success, string Message)> InitiateMFADisableAsync(int userId)
{
var user = await _v2Context.Users.FindAsync(userId);
if (user == null) return (false, "Usuario no encontrado.");
if (!user.IsMFAEnabled) return (false, "La autenticación de dos factores no está activa.");
// Generar Token Numérico simple (6 dígitos)
var token = new Random().Next(100000, 999999).ToString();
user.SecurityActionToken = token;
user.SecurityActionTokenExpiresAt = DateTime.UtcNow.AddMinutes(10);
await _v2Context.SaveChangesAsync();
var body = $@"
<div style='font-family: Arial, sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
<h2 style='color: #ef4444;'>Alerta de Seguridad</h2>
<p>Has solicitado <strong>DESACTIVAR</strong> la seguridad de dos factores (2FA) en tu cuenta.</p>
<p>Tu código de seguridad para confirmar esta acción es:</p>
<h1 style='letter-spacing: 5px; background: #f3f4f6; padding: 10px; display: inline-block; border-radius: 8px;'>{token}</h1>
<p>Si no fuiste tú, cambia tu contraseña inmediatamente.</p>
</div>";
await _emailService.SendEmailAsync(user.Email, "Código de Seguridad: Desactivar MFA", body);
return (true, "Por seguridad, hemos enviado un código de confirmación a tu correo electrónico.");
}
// 4. CONFIRMAR DESACTIVACIÓN
public async Task<(bool Success, string Message)> ConfirmMFADisableAsync(int userId, string token)
{
var user = await _v2Context.Users.FindAsync(userId);
if (user == null) return (false, "Usuario no encontrado.");
// Validar token
if (user.SecurityActionToken != token) return (false, "El código ingresado es incorrecto.");
if (user.SecurityActionTokenExpiresAt < DateTime.UtcNow) return (false, "El código ha expirado. Solicita uno nuevo.");
// Desactivar
user.IsMFAEnabled = false;
user.MFASecret = null;
user.SecurityActionToken = null;
user.SecurityActionTokenExpiresAt = null;
_v2Context.AuditLogs.Add(new AuditLog
{
Action = "SECURITY_MFA_DISABLED",
Entity = "User",
EntityID = user.UserID,
UserID = user.UserID,
Details = "MFA Desactivado mediante validación por correo."
});
await _v2Context.SaveChangesAsync();
return (true, "La autenticación de dos factores ha sido desactivada.");
}
// 5. INICIAR RECONFIGURACIÓN MFA (Envía código al mail actual)
public async Task<(bool Success, string Message)> InitiateMFAReconfigureAsync(int userId)
{
var user = await _v2Context.Users.FindAsync(userId);
if (user == null) return (false, "Usuario no encontrado.");
if (!user.IsMFAEnabled) return (false, "La seguridad 2FA no está activa para reconfigurar.");
// Reutilizamos el mismo token de seguridad
var token = new Random().Next(100000, 999999).ToString();
user.SecurityActionToken = token;
user.SecurityActionTokenExpiresAt = DateTime.UtcNow.AddMinutes(10);
await _v2Context.SaveChangesAsync();
var body = $@"
<div style='font-family: Arial, sans-serif; padding: 20px;'>
<h2 style='color: #f59e0b;'>Alerta de Seguridad</h2>
<p>Has solicitado <strong>RECONFIGURAR</strong> la seguridad de dos factores (2FA) en tu cuenta.</p>
<p>Tu código de seguridad para autorizar esta acción es:</p>
<h1 style='letter-spacing: 5px;'>{token}</h1>
<p>Si no fuiste tú, cambia tu contraseña inmediatamente.</p>
</div>";
await _emailService.SendEmailAsync(user.Email, "Código de Seguridad: Reconfigurar 2FA", body);
return (true, "Hemos enviado un código a tu correo para autorizar la reconfiguración.");
}
}