using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using MotoresArgentinosV2.Core.Interfaces; using MotoresArgentinosV2.Infrastructure.Data; using MotoresArgentinosV2.Core.Entities; using MotoresArgentinosV2.Core.DTOs; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.RateLimiting; namespace MotoresArgentinosV2.API.Controllers; [ApiController] [Route("api/[controller]")] [EnableRateLimiting("AuthPolicy")] public class AuthController : ControllerBase { private readonly IIdentityService _identityService; private readonly ITokenService _tokenService; private readonly INotificationService _notificationService; private readonly MotoresV2DbContext _context; public AuthController(IIdentityService identityService, ITokenService tokenService, INotificationService notificationService, MotoresV2DbContext context) { _identityService = identityService; _tokenService = tokenService; _notificationService = notificationService; _context = context; } // Helper privado para cookies private void SetTokenCookie(string token, string cookieName) { var cookieOptions = new CookieOptions { HttpOnly = true, // Seguridad: JS no puede leer esto Expires = DateTime.UtcNow.AddMinutes(15), Secure = true, // Solo HTTPS (localhost con https cuenta) SameSite = SameSiteMode.Strict, IsEssential = true }; Response.Cookies.Append(cookieName, token, cookieOptions); } [HttpPost("login")] [EnableRateLimiting("AuthPolicy")] // PROTEGIDO (5 intentos/min) public async Task Login([FromBody] LoginRequest request) { var (user, message) = await _identityService.AuthenticateAsync(request.Username, request.Password); if (user == null) { if (message == "EMAIL_NOT_VERIFIED") return Unauthorized(new { message = "Debes verificar tu email antes de ingresar." }); if (message == "USER_BLOCKED") return Unauthorized(new { message = "Tu usuario ha sido bloqueado por un administrador." }); return Unauthorized(new { message = "Credenciales inválidas" }); } if (message == "FORCE_PASSWORD_CHANGE") { return Ok(new { status = "MIGRATION_REQUIRED", username = user.UserName }); } // Lógica MFA (Si aplica) if (user.IsMFAEnabled) { return Ok(new { status = "TOTP_REQUIRED", username = user.UserName }); } if (user.UserType == 3 && string.IsNullOrEmpty(user.MFASecret)) // Admin forzar setup { var secret = _tokenService.GenerateBase32Secret(); user.MFASecret = secret; await _context.SaveChangesAsync(); var qrUri = _tokenService.GetQrCodeUri(user.Email, secret); return Ok(new { status = "MFA_SETUP_REQUIRED", username = user.UserName, qrUri, secret }); } // --- LOGIN EXITOSO --- // 1. Generar Tokens var jwtToken = _tokenService.GenerateJwtToken(user); var refreshToken = _tokenService.GenerateRefreshToken(HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0"); // 2. Guardar RefreshToken en DB // Asegúrate de que la propiedad RefreshTokens exista en User (Paso 2 abajo) user.RefreshTokens.Add(refreshToken); await _context.SaveChangesAsync(); // 3. Setear Cookies SetTokenCookie(jwtToken, "accessToken"); SetTokenCookie(refreshToken.Token, "refreshToken"); // 4. Audit Log _context.AuditLogs.Add(new AuditLog { Action = "LOGIN_SUCCESS", Entity = "User", EntityID = user.UserID, UserID = user.UserID, Details = "Login con Cookies exitoso" }); await _context.SaveChangesAsync(); // 5. Retornar User (Sin el token, porque va en cookie) return Ok(new { status = "SUCCESS", recommendMfa = !user.IsMFAEnabled, user = new { id = user.UserID, username = user.UserName, email = user.Email, firstName = user.FirstName, lastName = user.LastName, userType = user.UserType, isMFAEnabled = user.IsMFAEnabled } }); } [HttpPost("refresh-token")] // NO PROTEGIDO ESTRICTAMENTE (Usa límite global) public async Task RefreshToken() { var refreshToken = Request.Cookies["refreshToken"]; if (string.IsNullOrEmpty(refreshToken)) return Unauthorized(new { message = "Token no proporcionado" }); var user = await _context.Users .Include(u => u.RefreshTokens) .FirstOrDefaultAsync(u => u.RefreshTokens.Any(t => t.Token == refreshToken)); if (user == null) return Unauthorized(new { message = "Usuario no encontrado" }); var storedToken = user.RefreshTokens.SingleOrDefault(x => x.Token == refreshToken); if (storedToken == null || !storedToken.IsActive) return Unauthorized(new { message = "Token inválido o expirado" }); // Rotar Refresh Token var newRefreshToken = _tokenService.GenerateRefreshToken(HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0"); storedToken.Revoked = DateTime.UtcNow; storedToken.RevokedByIp = HttpContext.Connection.RemoteIpAddress?.ToString(); storedToken.ReplacedByToken = newRefreshToken.Token; user.RefreshTokens.Add(newRefreshToken); await _context.SaveChangesAsync(); // Nuevo JWT var newJwtToken = _tokenService.GenerateJwtToken(user); // Actualizar Cookies SetTokenCookie(newJwtToken, "accessToken"); SetTokenCookie(newRefreshToken.Token, "refreshToken"); return Ok(new { message = "Token renovado" }); } [HttpPost("logout")] // NO PROTEGIDO ESTRICTAMENTE public IActionResult Logout() { Response.Cookies.Delete("accessToken"); Response.Cookies.Delete("refreshToken"); return Ok(new { message = "Sesión cerrada" }); } [HttpGet("me")] // 1. Sin [Authorize] para controlar nosotros la respuesta public async Task GetMe() { // 2. Verificamos si existe la cookie de acceso var hasToken = Request.Cookies.ContainsKey("accessToken"); // 3. Si NO tiene cookie, es un visitante anónimo. // Devolvemos 200 OK con null para no ensuciar la consola con 401. if (!hasToken) { return Ok(null); } // 4. Si TIENE cookie, verificamos si el Middleware pudo leer el usuario. var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; // Si tiene cookie pero userIdStr es null, significa que el token expiró o es inválido. // Aquí SI devolvemos 401 para disparar el "Auto Refresh" del frontend. if (string.IsNullOrEmpty(userIdStr)) { return Unauthorized(); } // 5. Todo válido, buscamos datos var userId = int.Parse(userIdStr); var user = await _context.Users.FindAsync(userId); if (user == null) return Unauthorized(); // Usuario borrado de DB return Ok(new { id = user.UserID, username = user.UserName, email = user.Email, firstName = user.FirstName, lastName = user.LastName, userType = user.UserType, phoneNumber = user.PhoneNumber, isMFAEnabled = user.IsMFAEnabled }); } // Permite a un usuario logueado iniciar el proceso de MFA voluntariamente [Authorize] [HttpPost("init-mfa")] public async Task InitMFA() { var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(userIdStr)) return Unauthorized(); var user = await _context.Users.FindAsync(int.Parse(userIdStr)); if (user == null) return NotFound(); // Generar secreto si no tiene if (string.IsNullOrEmpty(user.MFASecret)) { user.MFASecret = _tokenService.GenerateBase32Secret(); await _context.SaveChangesAsync(); } // 📝 AUDITORÍA _context.AuditLogs.Add(new AuditLog { Action = "MFA_INITIATED", Entity = "User", EntityID = user.UserID, UserID = user.UserID, Details = "Proceso de configuración de MFA (TOTP) iniciado." }); await _context.SaveChangesAsync(); var qrUri = _tokenService.GetQrCodeUri(user.Email, user.MFASecret); return Ok(new { qrUri = qrUri, secret = user.MFASecret }); } [HttpPost("verify-mfa")] [EnableRateLimiting("AuthPolicy")] public async Task VerifyMFA([FromBody] MFARequest request) { var user = await _context.Users.FirstOrDefaultAsync(u => u.UserName == request.Username); if (user == null || string.IsNullOrEmpty(user.MFASecret)) { return Unauthorized(new { message = "Sesión de autenticación inválida" }); } bool isValid = _tokenService.ValidateTOTP(user.MFASecret, request.Code); if (!isValid) { return Unauthorized(new { message = "Código de verificación inválido" }); } if (!user.IsMFAEnabled) { user.IsMFAEnabled = true; await _context.SaveChangesAsync(); } var token = _tokenService.GenerateJwtToken(user); var refreshToken = _tokenService.GenerateRefreshToken(HttpContext.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0"); // Cargar colección explícitamente para evitar errores de EF await _context.Entry(user).Collection(u => u.RefreshTokens).LoadAsync(); user.RefreshTokens.Add(refreshToken); await _context.SaveChangesAsync(); // Setear Cookies Seguras SetTokenCookie(token, "accessToken"); SetTokenCookie(refreshToken.Token, "refreshToken"); _context.AuditLogs.Add(new AuditLog { Action = "LOGIN_TOTP_SUCCESS", Entity = "User", EntityID = user.UserID, UserID = user.UserID, Details = "Login con TOTP (Authenticator) exitoso" }); await _context.SaveChangesAsync(); return Ok(new { status = "SUCCESS", // user: retornamos datos para el frontend, pero NO el token user = new { id = user.UserID, username = user.UserName, email = user.Email, firstName = user.FirstName, lastName = user.LastName, userType = user.UserType, isMFAEnabled = user.IsMFAEnabled } }); } [Authorize] [HttpPost("disable-mfa")] public async Task DisableMFA() { var userIdStr = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(userIdStr)) return Unauthorized(); var user = await _context.Users.FindAsync(int.Parse(userIdStr)); if (user == null) return NotFound(); // Desactivamos MFA y limpiamos el secreto por seguridad user.IsMFAEnabled = false; user.MFASecret = null; // 📝 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." }); await _context.SaveChangesAsync(); return Ok(new { message = "MFA Desactivado correctamente." }); } [HttpPost("migrate-password")] [EnableRateLimiting("AuthPolicy")] public async Task MigratePassword([FromBody] MigrateRequest request) { var success = await _identityService.MigratePasswordAsync(request.Username, request.NewPassword); if (!success) { return BadRequest(new { message = "No se pudo actualizar la contraseña" }); } return Ok(new { message = "Contraseña actualizada con éxito. Ya puede iniciar sesión." }); } [HttpPost("register")] [EnableRateLimiting("AuthPolicy")] // PROTEGIDO public async Task Register([FromBody] RegisterRequest request) { var (success, message) = await _identityService.RegisterUserAsync(request); if (!success) return BadRequest(new { message }); // 📝 AUDITORÍA _context.AuditLogs.Add(new AuditLog { Action = "USER_REGISTERED", Entity = "User", EntityID = 0, UserID = 0, Details = $"Nuevo registro de usuario: {request.Email}" }); await _context.SaveChangesAsync(); return Ok(new { message }); } [HttpPost("verify-email")] [EnableRateLimiting("AuthPolicy")] // PROTEGIDO public async Task VerifyEmail([FromBody] VerifyEmailRequest request) { var (success, message) = await _identityService.VerifyEmailAsync(request.Token); if (!success) return BadRequest(new { message }); // 📝 AUDITORÍA _context.AuditLogs.Add(new AuditLog { Action = "EMAIL_VERIFIED", Entity = "User", EntityID = 0, UserID = 0, Details = "Correo electrónico verificado con éxito vía token." }); await _context.SaveChangesAsync(); return Ok(new { message }); } [HttpPost("resend-verification")] [EnableRateLimiting("AuthPolicy")] // PROTEGIDO public async Task ResendVerification([FromBody] ResendVerificationRequest request) { var (success, message) = await _identityService.ResendVerificationEmailAsync(request.Email); if (!success) return BadRequest(new { message }); return Ok(new { message }); } [HttpPost("forgot-password")] [EnableRateLimiting("AuthPolicy")] // PROTEGIDO public async Task ForgotPassword([FromBody] ForgotPasswordRequest request) { var (success, message) = await _identityService.ForgotPasswordAsync(request.Email); if (!success) { // Si falló por Rate Limit, devolvemos BadRequest o 429 Too Many Requests return BadRequest(new { message }); } return Ok(new { message }); } [HttpPost("reset-password")] [EnableRateLimiting("AuthPolicy")] // PROTEGIDO public async Task ResetPassword([FromBody] ResetPasswordRequest request) { var (success, message) = await _identityService.ResetPasswordAsync(request.Token, request.NewPassword); if (!success) return BadRequest(new { message }); // 📝 AUDITORÍA _context.AuditLogs.Add(new AuditLog { Action = "PASSWORD_RESET_SUCCESS", Entity = "User", EntityID = 0, // No tenemos el ID directo aquí sin buscarlo UserID = 0, Details = "Contraseña restablecida vía token de recuperación." }); await _context.SaveChangesAsync(); return Ok(new { message }); } [Authorize] [HttpPost("change-password")] [EnableRateLimiting("AuthPolicy")] // PROTEGIDO public async Task ChangePassword([FromBody] ChangePasswordRequest request) { var userId = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "0"); var (success, message) = await _identityService.ChangePasswordAsync(userId, request.CurrentPassword, request.NewPassword); if (!success) return BadRequest(new { message }); // Enviar notificación por mail try { var user = await _context.Users.FindAsync(userId); if (user != null) { await _notificationService.SendSecurityAlertEmailAsync(user.Email, "Cambio de contraseña"); } } catch (Exception) { /* No bloqueamos el éxito si falla el mail */ } // 📝 AUDITORÍA _context.AuditLogs.Add(new AuditLog { Action = "PASSWORD_CHANGED", Entity = "User", EntityID = userId, UserID = userId, Details = "Contraseña cambiada por el usuario desde la configuración." }); await _context.SaveChangesAsync(); return Ok(new { message }); } } public class ChangePasswordRequest { public string CurrentPassword { get; set; } = string.Empty; public string NewPassword { get; set; } = string.Empty; }