Init Commit

This commit is contained in:
2026-01-29 13:43:44 -03:00
commit b9aa8478db
126 changed files with 20649 additions and 0 deletions

View File

@@ -0,0 +1,485 @@
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]")]
// CORRECCIÓN: Se quitó [EnableRateLimiting("AuthPolicy")] de aquí para no bloquear /me ni /logout
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.AddDays(7),
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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;
}