336 lines
15 KiB
C#
336 lines
15 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using MotoresArgentinosV2.Core.Entities;
|
|
using MotoresArgentinosV2.Core.Interfaces;
|
|
using MotoresArgentinosV2.Core.DTOs;
|
|
using MotoresArgentinosV2.Infrastructure.Data;
|
|
using System.Security.Cryptography;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace MotoresArgentinosV2.Infrastructure.Services;
|
|
|
|
public class IdentityService : IIdentityService
|
|
{
|
|
private readonly MotoresV2DbContext _v2Context;
|
|
private readonly IPasswordService _passwordService;
|
|
private readonly IEmailService _emailService;
|
|
private readonly IConfiguration _config;
|
|
|
|
public IdentityService(
|
|
MotoresV2DbContext v2Context,
|
|
IPasswordService passwordService,
|
|
IEmailService emailService,
|
|
IConfiguration config)
|
|
{
|
|
_v2Context = v2Context;
|
|
_passwordService = passwordService;
|
|
_emailService = emailService;
|
|
_config = config;
|
|
}
|
|
|
|
public async Task<(bool Success, string Message)> RegisterUserAsync(RegisterRequest request)
|
|
{
|
|
// 1. Normalización
|
|
request.Username = request.Username.ToLowerInvariant().Trim();
|
|
request.Email = request.Email.ToLowerInvariant().Trim();
|
|
|
|
if (!Regex.IsMatch(request.Username, "^[a-z0-9]{4,20}$"))
|
|
return (false, "El usuario debe tener entre 4 y 20 caracteres, solo letras y números.");
|
|
|
|
// 2. Verificar Existencia
|
|
var existingUser = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == request.Email);
|
|
|
|
// CASO ESPECIAL: Usuario Fantasma (MigrationStatus = 1 pero sin password válido y no verificado)
|
|
// Si el email existe, le decimos al usuario que use "Recuperar Contraseña".
|
|
|
|
if (existingUser != null)
|
|
{
|
|
// Si es un usuario fantasma (sin password útil o marcado como tal),
|
|
// lo ideal es que el usuario haga el flujo de "Olvidé mi contraseña" para setearla y verificar el mail.
|
|
return (false, "Este correo ya está registrado. Si te pertenece, usa 'Olvidé mi contraseña' para activar tu cuenta.");
|
|
}
|
|
|
|
var userExists = await _v2Context.Users.AnyAsync(u => u.UserName == request.Username);
|
|
if (userExists) return (false, "Este nombre de usuario ya está en uso.");
|
|
|
|
// 3. Crear Token
|
|
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
|
|
|
// 4. Crear Usuario
|
|
var newUser = new User
|
|
{
|
|
UserName = request.Username,
|
|
Email = request.Email,
|
|
FirstName = request.FirstName,
|
|
LastName = request.LastName,
|
|
PhoneNumber = request.PhoneNumber,
|
|
PasswordHash = _passwordService.HashPassword(request.Password),
|
|
MigrationStatus = 1,
|
|
UserType = 1,
|
|
IsEmailVerified = false,
|
|
VerificationToken = token,
|
|
VerificationTokenExpiresAt = DateTime.UtcNow.AddHours(24),
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_v2Context.Users.Add(newUser);
|
|
await _v2Context.SaveChangesAsync();
|
|
|
|
// 4. Enviar Email REAL
|
|
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
|
|
var verifyLink = $"{frontendUrl}/verificar-email?token={token}";
|
|
|
|
var emailBody = $@"
|
|
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
|
|
<h2 style='color: #0051ff; text-align: center;'>Bienvenido a Motores Argentinos</h2>
|
|
<p>Hola <strong>{request.FirstName}</strong>,</p>
|
|
<p>Gracias por registrarte. Para activar tu cuenta y comenzar a publicar, por favor confirma tu correo electrónico haciendo clic en el siguiente botón:</p>
|
|
<div style='text-align: center; margin: 30px 0;'>
|
|
<a href='{verifyLink}' style='background-color: #0051ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;'>VERIFICAR MI CUENTA</a>
|
|
</div>
|
|
<p style='font-size: 12px; color: #666;'>Si no puedes hacer clic en el botón, copia y pega este enlace en tu navegador:</p>
|
|
<p style='font-size: 12px; color: #0051ff; word-break: break-all;'>{verifyLink}</p>
|
|
<hr style='border: 0; border-top: 1px solid #eee; margin: 20px 0;' />
|
|
<p style='font-size: 10px; color: #999; text-align: center;'>© 2026 Motores Argentinos. Este es un mensaje automático, por favor no respondas.</p>
|
|
</div>";
|
|
|
|
try
|
|
{
|
|
await _emailService.SendEmailAsync(request.Email, "Activa tu cuenta - Motores Argentinos", emailBody);
|
|
return (true, "Usuario registrado. Hemos enviado un correo de verificación a tu casilla.");
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Logueamos error pero retornamos true para no bloquear UX, el usuario pedirá reenvío luego.
|
|
return (true, "Usuario creado. Hubo un problema enviando el correo, intente ingresar para reenviarlo.");
|
|
}
|
|
}
|
|
|
|
public async Task<(bool Success, string Message)> VerifyEmailAsync(string token)
|
|
{
|
|
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.VerificationToken == token);
|
|
if (user == null) return (false, "Token inválido.");
|
|
if (user.VerificationTokenExpiresAt < DateTime.UtcNow) return (false, "El enlace ha expirado.");
|
|
|
|
user.IsEmailVerified = true;
|
|
user.VerificationToken = null;
|
|
user.VerificationTokenExpiresAt = null;
|
|
|
|
await _v2Context.SaveChangesAsync();
|
|
return (true, "Email verificado correctamente.");
|
|
}
|
|
|
|
public async Task<(User? User, string? MigrationMessage)> AuthenticateAsync(string username, string password)
|
|
{
|
|
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.UserName == username);
|
|
if (user == null) return (null, null);
|
|
|
|
// Validar Bloqueo
|
|
if (user.IsBlocked) return (null, "USER_BLOCKED");
|
|
|
|
// Validar Verificación de Email (Solo para usuarios modernos o ya migrados)
|
|
if (!user.IsEmailVerified && user.MigrationStatus == 1) return (null, "EMAIL_NOT_VERIFIED");
|
|
|
|
bool isLegacy = user.MigrationStatus == 0;
|
|
bool isValid = _passwordService.VerifyPassword(password, user.PasswordHash, user.PasswordSalt, isLegacy);
|
|
|
|
if (!isValid) return (null, null);
|
|
if (isLegacy) return (user, "FORCE_PASSWORD_CHANGE");
|
|
|
|
return (user, null);
|
|
}
|
|
|
|
public async Task<bool> MigratePasswordAsync(string username, string newPassword)
|
|
{
|
|
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.UserName == username);
|
|
if (user == null) return false;
|
|
|
|
user.PasswordHash = _passwordService.HashPassword(newPassword);
|
|
user.PasswordSalt = null;
|
|
user.MigrationStatus = 1;
|
|
user.IsEmailVerified = true; // Asumimos verificado al migrar
|
|
|
|
await _v2Context.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
public async Task<(bool Success, string Message)> ResendVerificationEmailAsync(string emailOrUsername)
|
|
{
|
|
// Buscar por Email O Username para mayor flexibilidad
|
|
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == emailOrUsername || u.UserName == emailOrUsername);
|
|
|
|
if (user == null) return (false, "No se encontró una cuenta con ese dato.");
|
|
|
|
if (user.IsEmailVerified) return (false, "Esta cuenta ya está verificada. Puede iniciar sesión.");
|
|
|
|
// --- RATE LIMITING ---
|
|
var cooldown = TimeSpan.FromMinutes(5);
|
|
if (user.LastVerificationEmailSentAt.HasValue)
|
|
{
|
|
var timeSinceLastSend = DateTime.UtcNow - user.LastVerificationEmailSentAt.Value;
|
|
if (timeSinceLastSend < cooldown)
|
|
{
|
|
var remaining = Math.Ceiling((cooldown - timeSinceLastSend).TotalMinutes);
|
|
return (false, $"Por seguridad, debe esperar {remaining} minutos antes de solicitar un nuevo correo.");
|
|
}
|
|
}
|
|
|
|
// Nuevo Token
|
|
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
|
user.VerificationToken = token;
|
|
user.VerificationTokenExpiresAt = DateTime.UtcNow.AddHours(24);
|
|
user.LastVerificationEmailSentAt = DateTime.UtcNow;
|
|
|
|
await _v2Context.SaveChangesAsync();
|
|
|
|
// Email
|
|
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
|
|
var verifyLink = $"{frontendUrl}/verificar-email?token={token}";
|
|
|
|
var emailBody = $@"
|
|
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
|
|
<h2 style='color: #0051ff; text-align: center;'>Verifica tu cuenta</h2>
|
|
<p>Hola <strong>{user.FirstName}</strong>,</p>
|
|
<p>Has solicitado un nuevo enlace de verificación. Haz clic abajo para activar tu cuenta:</p>
|
|
<div style='text-align: center; margin: 30px 0;'>
|
|
<a href='{verifyLink}' style='background-color: #0051ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;'>VERIFICAR AHORA</a>
|
|
</div>
|
|
<p style='font-size: 12px; color: #666;'>Si no solicitaste este correo, ignóralo.</p>
|
|
</div>";
|
|
|
|
try
|
|
{
|
|
await _emailService.SendEmailAsync(user.Email, "Verificación de Cuenta - Reenvío", emailBody);
|
|
return (true, "Correo de verificación reenviado. Revise su bandeja de entrada.");
|
|
}
|
|
catch
|
|
{
|
|
return (false, "Error al enviar el correo. Intente más tarde.");
|
|
}
|
|
}
|
|
|
|
public async Task<(bool Success, string Message)> ForgotPasswordAsync(string emailOrUsername)
|
|
{
|
|
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == emailOrUsername || u.UserName == emailOrUsername);
|
|
|
|
if (user == null)
|
|
{
|
|
await Task.Delay(new Random().Next(100, 300));
|
|
return (true, "Si el correo existe en nuestro sistema, recibirás las instrucciones.");
|
|
}
|
|
|
|
// --- RATE LIMITING ---
|
|
var cooldown = TimeSpan.FromMinutes(5);
|
|
if (user.LastPasswordResetEmailSentAt.HasValue)
|
|
{
|
|
var timeSinceLastSend = DateTime.UtcNow - user.LastPasswordResetEmailSentAt.Value;
|
|
if (timeSinceLastSend < cooldown)
|
|
{
|
|
var remaining = Math.Ceiling((cooldown - timeSinceLastSend).TotalMinutes);
|
|
return (false, $"Por favor, espera {remaining} minutos antes de solicitar otro correo.");
|
|
}
|
|
}
|
|
|
|
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
|
user.PasswordResetToken = token;
|
|
user.PasswordResetTokenExpiresAt = DateTime.UtcNow.AddHours(1);
|
|
user.LastPasswordResetEmailSentAt = DateTime.UtcNow;
|
|
|
|
await _v2Context.SaveChangesAsync();
|
|
|
|
var frontendUrl = _config["AppSettings:FrontendUrl"] ?? "http://localhost:5173";
|
|
var resetLink = $"{frontendUrl}/restablecer-clave?token={token}";
|
|
|
|
var emailBody = $@"
|
|
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;'>
|
|
<h2 style='color: #0051ff; text-align: center;'>Recuperación de Contraseña</h2>
|
|
<p>Hola <strong>{user.FirstName}</strong>,</p>
|
|
<p>Recibimos una solicitud para restablecer tu contraseña en Motores Argentinos.</p>
|
|
<div style='text-align: center; margin: 30px 0;'>
|
|
<a href='{resetLink}' style='background-color: #0051ff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;'>RESTABLECER CLAVE</a>
|
|
</div>
|
|
<p style='font-size: 12px; color: #666;'>Este enlace expirará en 1 hora.</p>
|
|
</div>";
|
|
|
|
try
|
|
{
|
|
await _emailService.SendEmailAsync(user.Email, "Restablecer Contraseña - Motores Argentinos", emailBody);
|
|
}
|
|
catch
|
|
{
|
|
return (false, "Hubo un error técnico enviando el correo. Intenta más tarde.");
|
|
}
|
|
|
|
return (true, "Si el correo existe en nuestro sistema, recibirás las instrucciones.");
|
|
}
|
|
|
|
public async Task<(bool Success, string Message)> ResetPasswordAsync(string token, string newPassword)
|
|
{
|
|
var user = await _v2Context.Users.FirstOrDefaultAsync(u => u.PasswordResetToken == token);
|
|
|
|
if (user == null) return (false, "El enlace es inválido.");
|
|
if (user.PasswordResetTokenExpiresAt < DateTime.UtcNow) return (false, "El enlace ha expirado. Solicita uno nuevo.");
|
|
|
|
user.PasswordHash = _passwordService.HashPassword(newPassword);
|
|
user.PasswordResetToken = null;
|
|
user.PasswordResetTokenExpiresAt = null;
|
|
user.PasswordSalt = null;
|
|
user.MigrationStatus = 1;
|
|
|
|
await _v2Context.SaveChangesAsync();
|
|
return (true, "Tu contraseña ha sido actualizada correctamente.");
|
|
}
|
|
|
|
public async Task<(bool Success, string Message)> ChangePasswordAsync(int userId, string current, string newPwd)
|
|
{
|
|
var user = await _v2Context.Users.FindAsync(userId);
|
|
if (user == null) return (false, "Usuario no encontrado");
|
|
|
|
if (!_passwordService.VerifyPassword(current, user.PasswordHash, user.PasswordSalt, user.MigrationStatus == 0))
|
|
return (false, "La contraseña actual es incorrecta.");
|
|
|
|
user.PasswordHash = _passwordService.HashPassword(newPwd);
|
|
user.PasswordSalt = null;
|
|
user.MigrationStatus = 1;
|
|
|
|
await _v2Context.SaveChangesAsync();
|
|
return (true, "Contraseña actualizada.");
|
|
}
|
|
|
|
// Implementación del método de creación de usuario fantasma para Admin
|
|
public async Task<User> CreateGhostUserAsync(string email, string firstName, string lastName, string phone)
|
|
{
|
|
var existing = await _v2Context.Users.FirstOrDefaultAsync(u => u.Email == email);
|
|
if (existing != null) return existing;
|
|
|
|
// Generar username base desde el email (parte izquierda)
|
|
string baseUsername = email.Split('@')[0].ToLowerInvariant();
|
|
baseUsername = Regex.Replace(baseUsername, "[^a-z0-9]", "");
|
|
|
|
// Asegurar unicidad simple
|
|
string finalUsername = baseUsername;
|
|
int count = 1;
|
|
while (await _v2Context.Users.AnyAsync(u => u.UserName == finalUsername))
|
|
{
|
|
finalUsername = $"{baseUsername}{count++}";
|
|
}
|
|
|
|
var user = new User
|
|
{
|
|
UserName = finalUsername,
|
|
Email = email,
|
|
FirstName = firstName,
|
|
LastName = lastName,
|
|
PhoneNumber = phone,
|
|
PasswordHash = _passwordService.HashPassword(Guid.NewGuid().ToString()),
|
|
MigrationStatus = 1,
|
|
UserType = 1,
|
|
IsEmailVerified = false,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_v2Context.Users.Add(user);
|
|
await _v2Context.SaveChangesAsync();
|
|
return user;
|
|
}
|
|
} |