485 lines
17 KiB
C#
485 lines
17 KiB
C#
|
|
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;
|
||
|
|
}
|