2026-01-29 13:43:44 -03:00
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] ")]
2026-01-30 11:18:56 -03:00
[EnableRateLimiting("AuthPolicy")]
2026-01-29 13:43:44 -03:00
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
2026-01-30 11:18:56 -03:00
Expires = DateTime . UtcNow . AddMinutes ( 15 ) ,
2026-02-13 11:23:16 -03:00
Secure = true , // Solo HTTPS (Para tests locales 'Secure = false' temporalmente)
SameSite = SameSiteMode . Strict , // Protección CSRF (Strict para máxima seguridad, pero puede ser Lax si hay problemas con redirecciones y testeos locales)
2026-01-29 13:43:44 -03:00
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")]
2026-02-12 15:24:32 -03:00
public async Task < IActionResult > InitMFA ( [ FromBody ] ConfirmSecurityActionRequest request )
2026-01-29 13:43:44 -03:00
{
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 ( ) ;
2026-02-12 15:24:32 -03:00
// Si el MFA ya está activo, exigimos el token de seguridad
if ( user . IsMFAEnabled )
2026-01-29 13:43:44 -03:00
{
2026-02-12 15:24:32 -03:00
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 ;
2026-01-29 13:43:44 -03:00
}
2026-02-12 15:24:32 -03:00
// Generar secreto si no tiene (o si se está reconfigurando)
user . MFASecret = _tokenService . GenerateBase32Secret ( ) ;
await _context . SaveChangesAsync ( ) ;
2026-01-29 13:43:44 -03:00
// 📝 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 ) ;
2026-02-12 15:24:32 -03:00
return Ok ( new { qrUri , secret = user . MFASecret } ) ;
2026-01-29 13:43:44 -03:00
}
[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
}
} ) ;
}
2026-02-12 15:24:32 -03:00
// CAMBIO DE EMAIL
2026-01-29 13:43:44 -03:00
[Authorize]
2026-02-12 15:24:32 -03:00
[HttpPost("initiate-email-change")]
public async Task < IActionResult > InitiateEmailChange ( [ FromBody ] InitiateEmailChangeRequest request )
2026-01-29 13:43:44 -03:00
{
2026-02-12 15:24:32 -03:00
var userId = int . Parse ( User . FindFirst ( System . Security . Claims . ClaimTypes . NameIdentifier ) ? . Value ? ? "0" ) ;
var ( success , message ) = await _identityService . InitiateEmailChangeAsync ( userId , request . NewEmail , request . MfaCode ) ;
2026-01-29 13:43:44 -03:00
2026-02-12 15:24:32 -03:00
if ( ! success ) return BadRequest ( new { message } ) ;
return Ok ( new { message } ) ;
}
2026-01-29 13:43:44 -03:00
2026-02-12 15:24:32 -03:00
[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 } ) ;
}
2026-01-29 13:43:44 -03:00
2026-02-12 15:24:32 -03:00
// 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 ) ;
2026-01-29 13:43:44 -03:00
2026-02-12 15:24:32 -03:00
if ( ! success ) return BadRequest ( new { message } ) ;
return Ok ( new { message } ) ;
}
2026-01-29 13:43:44 -03:00
2026-02-12 15:24:32 -03:00
[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 } ) ;
2026-01-29 13:43:44 -03:00
}
[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 ;
}