From e096ed1590be171e88f2c1f5dc08cf4f035aa949 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 12 Feb 2026 15:24:32 -0300 Subject: [PATCH] =?UTF-8?q?Feat:=20Seguridad=20avanzada=20para=20cambio=20?= =?UTF-8?q?de=20email=20y=20gesti=C3=B3n=20de=20MFA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../Controllers/AuthController.cs | 90 +++-- .../DTOs/SecurityDtos.cs | 17 + .../Entities/AppEntities.cs | 9 + .../Interfaces/IIdentityService.cs | 8 + .../Services/IdentityService.cs | 186 ++++++++++ Frontend/src/App.tsx | 2 + Frontend/src/components/ConfigPanel.tsx | 342 ++++++++++++++---- Frontend/src/pages/ConfirmEmailChangePage.tsx | 85 +++++ Frontend/src/pages/PerfilPage.tsx | 204 +++++++++-- Frontend/src/services/auth.service.ts | 117 ++++-- 10 files changed, 891 insertions(+), 169 deletions(-) create mode 100644 Backend/MotoresArgentinosV2.Core/DTOs/SecurityDtos.cs create mode 100644 Frontend/src/pages/ConfirmEmailChangePage.tsx diff --git a/Backend/MotoresArgentinosV2.API/Controllers/AuthController.cs b/Backend/MotoresArgentinosV2.API/Controllers/AuthController.cs index 141f69d..f109819 100644 --- a/Backend/MotoresArgentinosV2.API/Controllers/AuthController.cs +++ b/Backend/MotoresArgentinosV2.API/Controllers/AuthController.cs @@ -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 InitMFA() + public async Task 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 DisableMFA() + [HttpPost("initiate-email-change")] + public async Task 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 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 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 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 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")] diff --git a/Backend/MotoresArgentinosV2.Core/DTOs/SecurityDtos.cs b/Backend/MotoresArgentinosV2.Core/DTOs/SecurityDtos.cs new file mode 100644 index 0000000..fabad98 --- /dev/null +++ b/Backend/MotoresArgentinosV2.Core/DTOs/SecurityDtos.cs @@ -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 +} \ No newline at end of file diff --git a/Backend/MotoresArgentinosV2.Core/Entities/AppEntities.cs b/Backend/MotoresArgentinosV2.Core/Entities/AppEntities.cs index bd4ee05..80a897e 100644 --- a/Backend/MotoresArgentinosV2.Core/Entities/AppEntities.cs +++ b/Backend/MotoresArgentinosV2.Core/Entities/AppEntities.cs @@ -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; diff --git a/Backend/MotoresArgentinosV2.Core/Interfaces/IIdentityService.cs b/Backend/MotoresArgentinosV2.Core/Interfaces/IIdentityService.cs index fa97c8e..8f52758 100644 --- a/Backend/MotoresArgentinosV2.Core/Interfaces/IIdentityService.cs +++ b/Backend/MotoresArgentinosV2.Core/Interfaces/IIdentityService.cs @@ -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 CreateGhostUserAsync(string email, string firstName, string lastName, string phone); } \ No newline at end of file diff --git a/Backend/MotoresArgentinosV2.Infrastructure/Services/IdentityService.cs b/Backend/MotoresArgentinosV2.Infrastructure/Services/IdentityService.cs index 5740a07..73099c7 100644 --- a/Backend/MotoresArgentinosV2.Infrastructure/Services/IdentityService.cs +++ b/Backend/MotoresArgentinosV2.Infrastructure/Services/IdentityService.cs @@ -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 = $@" +
+

Confirma tu nuevo correo

+

Hemos recibido una solicitud para transferir tu cuenta de Motores Argentinos a esta dirección.

+

Si fuiste tú, haz clic en el siguiente enlace para confirmar el cambio:

+ +

El enlace expira en 30 minutos.

+
"; + + 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 = $@" +
+

Alerta de Seguridad

+

Has solicitado DESACTIVAR la seguridad de dos factores (2FA) en tu cuenta.

+

Tu código de seguridad para confirmar esta acción es:

+

{token}

+

Si no fuiste tú, cambia tu contraseña inmediatamente.

+
"; + + 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 = $@" +
+

Alerta de Seguridad

+

Has solicitado RECONFIGURAR la seguridad de dos factores (2FA) en tu cuenta.

+

Tu código de seguridad para autorizar esta acción es:

+

{token}

+

Si no fuiste tú, cambia tu contraseña inmediatamente.

+
"; + + 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."); + } } \ No newline at end of file diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index 8dbc045..3bfcb65 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -16,6 +16,7 @@ import SeguridadPage from './pages/SeguridadPage'; import { FaHome, FaSearch, FaCar, FaUser, FaShieldAlt } from 'react-icons/fa'; import { initMercadoPago } from '@mercadopago/sdk-react'; import { AuthProvider, useAuth } from './context/AuthContext'; +import ConfirmEmailChangePage from './pages/ConfirmEmailChangePage'; function AdminGuard({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); @@ -305,6 +306,7 @@ function MainLayout() { } /> } /> } /> + } /> diff --git a/Frontend/src/components/ConfigPanel.tsx b/Frontend/src/components/ConfigPanel.tsx index 8ecd643..3d6d3f9 100644 --- a/Frontend/src/components/ConfigPanel.tsx +++ b/Frontend/src/components/ConfigPanel.tsx @@ -1,41 +1,81 @@ -import { useState } from 'react'; -import { AuthService, type UserSession } from '../services/auth.service'; -import { QRCodeSVG } from 'qrcode.react'; -import ChangePasswordModal from './ChangePasswordModal'; -import { useAuth } from '../context/AuthContext'; +import { useState } from "react"; +import { AuthService, type UserSession } from "../services/auth.service"; +import { QRCodeSVG } from "qrcode.react"; +import ChangePasswordModal from "./ChangePasswordModal"; +import { useAuth } from "../context/AuthContext"; // Iconos -const CopyIcon = () => (); -const CheckIcon = () => (); +const CopyIcon = () => ( + + + +); +const CheckIcon = () => ( + + + +); export default function ConfigPanel({ user }: { user: UserSession }) { - const { refreshSession } = useAuth(); // Para actualizar si isMFAEnabled cambia en el contexto + const { refreshSession } = useAuth(); const [showPasswordModal, setShowPasswordModal] = useState(false); // Estados MFA - const [mfaStep, setMfaStep] = useState<'IDLE' | 'QR'>('IDLE'); - const [qrUri, setQrUri] = useState(''); - const [secretKey, setSecretKey] = useState(''); // El código manual - const [mfaCode, setMfaCode] = useState(''); - const [msgMfa, setMsgMfa] = useState({ text: '', type: '' }); // type: 'success' | 'error' + const [mfaStep, setMfaStep] = useState<"IDLE" | "QR">("IDLE"); + const [qrUri, setQrUri] = useState(""); + const [secretKey, setSecretKey] = useState(""); + const [mfaCode, setMfaCode] = useState(""); + const [msgMfa, setMsgMfa] = useState({ text: "", type: "" }); const [copied, setCopied] = useState(false); const [loading, setLoading] = useState(false); + + // Estados para flujos de verificación por Email + const [disableStep, setDisableStep] = useState<"IDLE" | "VERIFY">("IDLE"); + const [disableCode, setDisableCode] = useState(""); + const [reconfigureStep, setReconfigureStep] = useState<"IDLE" | "VERIFY">( + "IDLE", + ); + const [reconfigureCode, setReconfigureCode] = useState(""); + const isMfaActive = (user as any).isMFAEnabled; - const handleInitMfa = async () => { - if (isMfaActive) { - if (!window.confirm("Al reconfigurar, el código anterior dejará de funcionar en tu otro dispositivo. ¿Continuar?")) return; - } - + // Lógica para obtener el QR (Se llama directo para activar o con token para reconfigurar) + const handleInitMfa = async (securityCode?: string) => { setLoading(true); - setMsgMfa({ text: '', type: '' }); + setMsgMfa({ text: "", type: "" }); try { - const data = await AuthService.initMFA(); + const data = await AuthService.initMFA(securityCode); setQrUri(data.qrUri); setSecretKey(data.secret); - setMfaStep('QR'); - } catch { - setMsgMfa({ text: "Error iniciando configuración.", type: 'error' }); + setMfaStep("QR"); + setReconfigureStep("IDLE"); + setReconfigureCode(""); + } catch (err: any) { + setMsgMfa({ + text: + err.response?.data?.message || "Error al generar nuevo código QR.", + type: "error", + }); } finally { setLoading(false); } @@ -45,27 +85,75 @@ export default function ConfigPanel({ user }: { user: UserSession }) { setLoading(true); try { await AuthService.verifyMFA(user.username, mfaCode); - setMsgMfa({ text: "¡MFA Activado correctamente!", type: 'success' }); - setMfaStep('IDLE'); - setMfaCode(''); - await refreshSession(); // Actualizar estado global + setMsgMfa({ text: "¡MFA configurado con éxito!", type: "success" }); + setMfaStep("IDLE"); + setMfaCode(""); + await refreshSession(); } catch { - setMsgMfa({ text: "Código incorrecto. Intenta nuevamente.", type: 'error' }); + setMsgMfa({ text: "Código incorrecto.", type: "error" }); } finally { setLoading(false); } }; - const handleDisableMfa = async () => { - if (!window.confirm("¿Seguro que deseas desactivar la protección de dos factores? Tu cuenta será menos segura.")) return; - + // Flujo Desactivar + const handleInitiateDisable = async () => { + if ( + !window.confirm( + "Se enviará un código a tu correo para confirmar la desactivación. ¿Continuar?", + ) + ) + return; setLoading(true); try { - await AuthService.disableMFA(); - setMsgMfa({ text: "MFA Desactivado.", type: 'success' }); + await AuthService.initiateMfaDisable(); + setMsgMfa({ text: "Código enviado a tu email.", type: "success" }); + setDisableStep("VERIFY"); + setReconfigureStep("IDLE"); // Resetear el otro flujo por si acaso + } catch (err: any) { + setMsgMfa({ + text: err.response?.data?.message || "Error al iniciar.", + type: "error", + }); + } finally { + setLoading(false); + } + }; + + const handleConfirmDisable = async () => { + setLoading(true); + try { + await AuthService.confirmMfaDisable(disableCode); + setMsgMfa({ text: "MFA Desactivado.", type: "success" }); + setDisableStep("IDLE"); + setDisableCode(""); await refreshSession(); - } catch { - setMsgMfa({ text: "Error al desactivar MFA.", type: 'error' }); + } catch (err: any) { + setMsgMfa({ text: "Código incorrecto.", type: "error" }); + } finally { + setLoading(false); + } + }; + + // Flujo Reconfigurar + const handleInitiateReconfigure = async () => { + if ( + !window.confirm( + "Se enviará un código a tu correo para autorizar la reconfiguración. ¿Continuar?", + ) + ) + return; + setLoading(true); + try { + await AuthService.initiateMfaReconfigure(); + setMsgMfa({ text: "Código de autorización enviado.", type: "success" }); + setReconfigureStep("VERIFY"); + setDisableStep("IDLE"); // Resetear el otro flujo + } catch (err: any) { + setMsgMfa({ + text: err.response?.data?.message || "Error al iniciar.", + type: "error", + }); } finally { setLoading(false); } @@ -80,14 +168,15 @@ export default function ConfigPanel({ user }: { user: UserSession }) { return ( <>
- {/* SECCIÓN CONTRASEÑA */}
🔑
-

Contraseña

+

+ Contraseña +

Mantén tu cuenta segura actualizando tu contraseña periódicamente.

@@ -100,19 +189,25 @@ export default function ConfigPanel({ user }: { user: UserSession }) {
{/* SECCIÓN MFA */} -
- - {/* Indicador de Estado */} -
- {isMfaActive ? 'Protegido' : 'No Activo'} +
+
+ {isMfaActive ? "Protegido" : "No Activo"}
-
+
🛡️
-

Doble Factor (2FA)

+

+ Doble Factor (2FA) +

- {mfaStep === 'IDLE' ? ( + {mfaStep === "IDLE" ? (

{isMfaActive @@ -120,67 +215,176 @@ export default function ConfigPanel({ user }: { user: UserSession }) { : "Añade una capa extra de seguridad. Requerirá un código de tu celular al iniciar sesión."}

-
+
{isMfaActive ? ( -
- - -
+ <> + {disableStep === "VERIFY" ? ( + /* Verificación para Desactivar */ +
+

+ Código para Desactivar: +

+ + setDisableCode(e.target.value.replace(/\D/g, "")) + } + className="w-full bg-black/50 border border-white/10 rounded-lg px-3 py-3 text-center text-white text-xl font-mono tracking-[0.3em] outline-none mb-3 focus:border-red-500 transition-all" + /> +
+ + +
+
+ ) : reconfigureStep === "VERIFY" ? ( + /* Verificación para Reconfigurar */ +
+

+ Código para Reconfigurar: +

+ + setReconfigureCode( + e.target.value.replace(/\D/g, ""), + ) + } + className="w-full bg-black/50 border border-white/10 rounded-lg px-3 py-3 text-center text-white text-xl font-mono tracking-[0.3em] outline-none mb-3 focus:border-blue-500 transition-all" + /> +
+ + +
+
+ ) : ( + /* Botones Principales */ +
+ + +
+ )} + ) : ( - )} {msgMfa.text && ( -

+

{msgMfa.text}

)}
) : ( + /* PASO QR (Solo se llega aquí tras validar código o activar por primera vez) */
-

1. Escanea el código

-

Usa Google Authenticator o Authy en tu celular.

+

+ 1. Escanea el código +

+

+ Usa Google Authenticator o Authy en tu celular. +

- {/* CÓDIGO MANUAL */}
-

O ingresa el código manual

+

+ Código manual +

- {secretKey} + + {secretKey} +
-

2. Ingresa el token

+

+ 2. Ingresa el token +

setMfaCode(e.target.value.replace(/\D/g, ''))} + onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ""))} className="w-full bg-black/20 border border-white/10 rounded-xl px-4 py-3 text-center text-2xl font-black text-white mb-4 tracking-[0.4em] outline-none focus:border-blue-500 transition-colors placeholder:opacity-20" />
- - +
@@ -193,4 +397,4 @@ export default function ConfigPanel({ user }: { user: UserSession }) { )} ); -} \ No newline at end of file +} diff --git a/Frontend/src/pages/ConfirmEmailChangePage.tsx b/Frontend/src/pages/ConfirmEmailChangePage.tsx new file mode 100644 index 0000000..dc93bab --- /dev/null +++ b/Frontend/src/pages/ConfirmEmailChangePage.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { AuthService } from "../services/auth.service"; +import { useAuth } from "../context/AuthContext"; + +export default function ConfirmEmailChangePage() { + const [searchParams] = useSearchParams(); + const token = searchParams.get("token"); + const navigate = useNavigate(); + const { logout } = useAuth(); // Importante: Desloguear para forzar login con nuevo email + + const [status, setStatus] = useState<"LOADING" | "SUCCESS" | "ERROR">( + "LOADING", + ); + const [message, setMessage] = useState(""); + + useEffect(() => { + if (!token) { + setStatus("ERROR"); + setMessage("Enlace inválido."); + return; + } + + const confirm = async () => { + try { + await AuthService.confirmEmailChange(token); + setStatus("SUCCESS"); + // Forzar cierre de sesión por seguridad + setTimeout(() => { + logout(); + navigate("/"); + }, 4000); + } catch (err: any) { + setStatus("ERROR"); + setMessage(err.response?.data?.message || "Error al confirmar cambio."); + } + }; + + confirm(); + }, [token]); + + return ( +
+
+ {status === "LOADING" && ( + <> +
+

+ Verificando cambio... +

+ + )} + {status === "SUCCESS" && ( + <> +
+

+ ¡Email Actualizado! +

+

+ Tu dirección de correo ha sido cambiada correctamente. +

+

+ Cerrando sesión por seguridad... +

+ + )} + {status === "ERROR" && ( + <> +
+

+ Error +

+

{message}

+ + + )} +
+
+ ); +} diff --git a/Frontend/src/pages/PerfilPage.tsx b/Frontend/src/pages/PerfilPage.tsx index 8d4a2e2..6c80145 100644 --- a/Frontend/src/pages/PerfilPage.tsx +++ b/Frontend/src/pages/PerfilPage.tsx @@ -1,16 +1,22 @@ -import { useState, useEffect } from 'react'; -import { ProfileService } from '../services/profile.service'; -import { useAuth } from '../context/AuthContext'; +import { useState, useEffect } from "react"; +import { ProfileService } from "../services/profile.service"; +import { useAuth } from "../context/AuthContext"; +import { AuthService } from "../services/auth.service"; export default function PerfilPage() { const { user, refreshSession } = useAuth(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [showEmailModal, setShowEmailModal] = useState(false); + const [newEmail, setNewEmail] = useState(""); + const [authCode, setAuthCode] = useState(""); // Código Google Authenticator + const [loadingEmail, setLoadingEmail] = useState(false); + const [formData, setFormData] = useState({ - firstName: '', - lastName: '', - phoneNumber: '' + firstName: "", + lastName: "", + phoneNumber: "", }); useEffect(() => { @@ -21,9 +27,9 @@ export default function PerfilPage() { try { const data = await ProfileService.getProfile(); setFormData({ - firstName: data.firstName || '', - lastName: data.lastName || '', - phoneNumber: data.phoneNumber || '' + firstName: data.firstName || "", + lastName: data.lastName || "", + phoneNumber: data.phoneNumber || "", }); } catch (err) { console.error("Error loading profile", err); @@ -37,10 +43,10 @@ export default function PerfilPage() { setSaving(true); try { await ProfileService.updateProfile(formData); - alert('Perfil actualizado con éxito'); + alert("Perfil actualizado con éxito"); if (refreshSession) refreshSession(); } catch (err) { - alert('Error al actualizar el perfil'); + alert("Error al actualizar el perfil"); } finally { setSaving(false); } @@ -57,8 +63,12 @@ export default function PerfilPage() { return (
-

Mi Perfil

-

Gestiona tu información personal

+

+ Mi Perfil +

+

+ Gestiona tu información personal +

@@ -68,12 +78,20 @@ export default function PerfilPage() {
{user?.username?.[0].toUpperCase()}
-

{user?.username}

-

{user?.email}

+

+ {user?.username} +

+

+ {user?.email} +

- - {user?.userType === 3 ? '🛡️ Administrador' : '👤 Usuario Particular'} + + {user?.userType === 3 + ? "🛡️ Administrador" + : "👤 Usuario Particular"}
@@ -81,49 +99,83 @@ export default function PerfilPage() { {/* Edit Form */}
-
+
- + setFormData({ ...formData, firstName: e.target.value })} + onChange={(e) => + setFormData({ ...formData, firstName: e.target.value }) + } className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium" placeholder="Tu nombre" />
- + setFormData({ ...formData, lastName: e.target.value })} + onChange={(e) => + setFormData({ ...formData, lastName: e.target.value }) + } className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium" placeholder="Tu apellido" />
- + setFormData({ ...formData, phoneNumber: e.target.value })} + onChange={(e) => + setFormData({ ...formData, phoneNumber: e.target.value }) + } className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500 transition-all font-medium" placeholder="Ej: +54 9 11 1234 5678" />
- - + +
+ + +
@@ -133,12 +185,100 @@ export default function PerfilPage() { disabled={saving} className="w-full md:w-auto bg-blue-600 hover:bg-blue-500 text-white py-4 px-12 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-blue-600/20 active:scale-95 disabled:opacity-50" > - {saving ? 'Guardando...' : 'Guardar Cambios'} + {saving ? "Guardando..." : "Guardar Cambios"}
+ {showEmailModal && ( +
+
+ + +
+
+ 📧 +
+

+ Cambiar Email +

+

+ Ingresa tu nueva dirección y valida con tu autenticador. +

+
+ +
+
+ + setNewEmail(e.target.value)} + className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white text-sm outline-none focus:border-blue-500 transition-all placeholder:text-gray-700" + /> +
+ +
+ + + setAuthCode(e.target.value.replace(/\D/g, "")) + } + className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white text-center font-mono text-xl tracking-[0.3em] outline-none focus:border-blue-500 transition-all placeholder:text-gray-700 placeholder:tracking-normal" + /> +
+ + +
+
+
+ )}
); } diff --git a/Frontend/src/services/auth.service.ts b/Frontend/src/services/auth.service.ts index 64d0ada..d9c544c 100644 --- a/Frontend/src/services/auth.service.ts +++ b/Frontend/src/services/auth.service.ts @@ -1,4 +1,4 @@ -import apiClient from './axios.client'; +import apiClient from "./axios.client"; export interface UserSession { id: number; @@ -13,76 +13,82 @@ export interface UserSession { export const AuthService = { async login(username: string, password: string) { - const response = await apiClient.post('/auth/login', { username, password }); + const response = await apiClient.post("/auth/login", { + username, + password, + }); - if (response.data.status === 'MIGRATION_REQUIRED') { - return { status: 'MIGRATION_REQUIRED', username: response.data.username }; + if (response.data.status === "MIGRATION_REQUIRED") { + return { status: "MIGRATION_REQUIRED", username: response.data.username }; } - if (response.data.status === 'MFA_SETUP_REQUIRED') { + if (response.data.status === "MFA_SETUP_REQUIRED") { return { - status: 'MFA_SETUP_REQUIRED', + status: "MFA_SETUP_REQUIRED", username: response.data.username, qrUri: response.data.qrUri, - secret: response.data.secret + secret: response.data.secret, }; } - if (response.data.status === 'TOTP_REQUIRED') { + if (response.data.status === "TOTP_REQUIRED") { return { - status: 'TOTP_REQUIRED', + status: "TOTP_REQUIRED", username: response.data.username, - recommendMfa: response.data.recommendMfa + recommendMfa: response.data.recommendMfa, }; } - if (response.data.status === 'SUCCESS') { - localStorage.setItem('userProfile', JSON.stringify(response.data.user)); + if (response.data.status === "SUCCESS") { + localStorage.setItem("userProfile", JSON.stringify(response.data.user)); return { - status: 'SUCCESS', + status: "SUCCESS", user: response.data.user, - recommendMfa: response.data.recommendMfa + recommendMfa: response.data.recommendMfa, }; } - throw new Error('Respuesta inesperada del servidor'); + throw new Error("Respuesta inesperada del servidor"); }, async logout() { - await apiClient.post('/auth/logout'); - localStorage.removeItem('userProfile'); + await apiClient.post("/auth/logout"); + localStorage.removeItem("userProfile"); }, async register(data: any) { - const response = await apiClient.post('/auth/register', data); + const response = await apiClient.post("/auth/register", data); return response.data; }, async verifyEmail(token: string) { - const response = await apiClient.post('/auth/verify-email', { token }); + const response = await apiClient.post("/auth/verify-email", { token }); return response.data; }, async verifyMFA(username: string, code: string) { - const response = await apiClient.post('/auth/verify-mfa', { username, code }); - if (response.data.status === 'SUCCESS') { + const response = await apiClient.post("/auth/verify-mfa", { + username, + code, + }); + if (response.data.status === "SUCCESS") { this.setSession(response.data.user, response.data.token); return response.data.user; } }, setSession(user: UserSession, token: string) { - localStorage.setItem('session', JSON.stringify(user)); - localStorage.setItem('token', token); + localStorage.setItem("session", JSON.stringify(user)); + localStorage.setItem("token", token); }, async migratePassword(username: string, newPassword: string) { - await apiClient.post('/auth/migrate-password', { username, newPassword }); + await apiClient.post("/auth/migrate-password", { username, newPassword }); }, async checkSession() { try { - const response = await apiClient.get('/auth/me'); + const response = await apiClient.get("/auth/me"); // Si el backend devuelve 200 pero el body es null, no hay sesión. if (!response.data) { @@ -91,53 +97,88 @@ export const AuthService = { // Sincronizar localStorage con la sesión real del servidor // Esto asegura que 'MisAvisosPage' siempre tenga el ID correcto para consultar. - localStorage.setItem('userProfile', JSON.stringify(response.data)); + localStorage.setItem("userProfile", JSON.stringify(response.data)); return response.data; } catch (error) { // Si falla (token expirado), limpiamos - localStorage.removeItem('userProfile'); + localStorage.removeItem("userProfile"); return null; } }, getCurrentUser() { // Solo para visualización rápida, la verdad está en el backend - const session = localStorage.getItem('userProfile'); + const session = localStorage.getItem("userProfile"); return session ? JSON.parse(session) : null; }, getToken(): string | null { - return localStorage.getItem('token'); + return localStorage.getItem("token"); }, async resendVerification(email: string) { - const response = await apiClient.post('/auth/resend-verification', { email }); + const response = await apiClient.post("/auth/resend-verification", { + email, + }); return response.data; }, async forgotPassword(email: string) { - const response = await apiClient.post('/auth/forgot-password', { email }); + const response = await apiClient.post("/auth/forgot-password", { email }); return response.data; }, async resetPassword(token: string, newPassword: string) { - const response = await apiClient.post('/auth/reset-password', { token, newPassword }); + const response = await apiClient.post("/auth/reset-password", { + token, + newPassword, + }); return response.data; }, - async initMFA() { - const response = await apiClient.post('/auth/init-mfa'); + async initiateMfaReconfigure() { + const response = await apiClient.post("/auth/initiate-mfa-reconfigure"); + return response.data; + }, + + async initMFA(token?: string) { + const response = await apiClient.post("/auth/init-mfa", { token }); return response.data; }, async changePassword(currentPassword: string, newPassword: string) { - const response = await apiClient.post('/auth/change-password', { currentPassword, newPassword }); + const response = await apiClient.post("/auth/change-password", { + currentPassword, + newPassword, + }); return response.data; }, - async disableMFA() { - const response = await apiClient.post('/auth/disable-mfa'); + async initiateEmailChange(newEmail: string, mfaCode: string) { + const response = await apiClient.post("/auth/initiate-email-change", { + newEmail, + mfaCode, + }); return response.data; }, -}; \ No newline at end of file + + async confirmEmailChange(token: string) { + const response = await apiClient.post("/auth/confirm-email-change", { + token, + }); + return response.data; + }, + + async initiateMfaDisable() { + const response = await apiClient.post("/auth/initiate-mfa-disable"); + return response.data; + }, + + async confirmMfaDisable(token: string) { + const response = await apiClient.post("/auth/confirm-mfa-disable", { + token, + }); + return response.data; + }, +};