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:
@@ -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")]
|
||||
|
||||
17
Backend/MotoresArgentinosV2.Core/DTOs/SecurityDtos.cs
Normal file
17
Backend/MotoresArgentinosV2.Core/DTOs/SecurityDtos.cs
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user