From 1155bacac57c2c1b976a1990791bfe18c060b806 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 25 Apr 2026 10:36:26 +0200 Subject: [PATCH 1/2] Publica V-01.03 --- Atlas Balance/AGENTS.md | 2 +- Atlas Balance/CLAUDE.md | 2 +- Atlas Balance/Directory.Build.props | 8 +- Atlas Balance/README_RELEASE.md | 2 +- Atlas Balance/VERSION | 2 +- .../GestionCaja.API/ConfigurationDefaults.cs | 35 + .../GestionCaja.API/Constants/AuditActions.cs | 3 + .../Constants/AuthClaimNames.cs | 7 + .../Constants/SecurityPolicy.cs | 44 + .../Controllers/ConfiguracionController.cs | 80 +- .../Controllers/ExportacionesController.cs | 14 + .../Controllers/ExtractosController.cs | 11 +- .../Controllers/UsuariosController.cs | 60 +- .../src/GestionCaja.API/Data/AppDbContext.cs | 1 + .../src/GestionCaja.API/Data/SeedData.cs | 15 +- .../Middleware/IntegrationAuthMiddleware.cs | 53 +- .../Middleware/UserStateMiddleware.cs | 26 +- ...425081244_UserSessionHardening.Designer.cs | 1534 +++++++++++++++++ .../20260425081244_UserSessionHardening.cs | 41 + .../Migrations/AppDbContextModelSnapshot.cs | 10 + .../src/GestionCaja.API/Models/Entities.cs | 2 + .../Services/ActualizacionService.cs | 30 +- .../GestionCaja.API/Services/AuthService.cs | 173 +- .../GestionCaja.API/Services/BackupService.cs | 13 + .../Services/ExportacionService.cs | 13 + .../Services/UserSessionState.cs | 31 + .../Services/WatchdogOperationsService.cs | 60 +- .../ActualizacionServiceTests.cs | 28 + .../GestionCaja.API.Tests/AuthServiceTests.cs | 105 +- .../ConfiguracionControllerTests.cs | 35 +- .../ExportacionServiceTests.cs | 37 + .../ExtractosControllerTests.cs | 71 + .../IntegrationAuthMiddlewareTests.cs | 49 + .../UserStateMiddlewareTests.cs | 102 ++ .../UsuariosControllerTests.cs | 74 +- .../WatchdogOperationsServiceTests.cs | 31 +- Atlas Balance/frontend/package-lock.json | 10 +- Atlas Balance/frontend/package.json | 4 +- .../src/components/usuarios/UsuarioModal.tsx | 11 +- .../frontend/src/pages/ChangePasswordPage.tsx | 2 +- .../frontend/src/pages/CuentaDetailPage.tsx | 14 + .../frontend/src/pages/CuentasPage.tsx | 73 +- .../frontend/src/stores/permisosStore.ts | 29 +- Atlas Balance/scripts/Build-Release.ps1 | 2 +- .../scripts/Instalar-AtlasBalance.ps1 | 32 +- CLAUDE.md | 2 +- Documentacion/DOCUMENTACION_CAMBIOS.md | 297 ++++ Documentacion/DOCUMENTACION_TECNICA.md | 124 ++ Documentacion/DOCUMENTACION_USUARIO.md | 5 +- Documentacion/LOG_ERRORES_INCIDENCIAS.md | 46 + Documentacion/REGISTRO_BUGS.md | 16 + Documentacion/SEGURIDAD_AUDITORIA_V-01.03.md | 87 + Documentacion/Versiones/v-01.02.md | 4 +- Documentacion/Versiones/v-01.03.md | 89 + Documentacion/Versiones/version_actual.md | 9 +- Documentacion/documentacion.md | 28 +- 56 files changed, 3551 insertions(+), 137 deletions(-) create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Constants/AuthClaimNames.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Constants/SecurityPolicy.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425081244_UserSessionHardening.Designer.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425081244_UserSessionHardening.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Services/UserSessionState.cs create mode 100644 Atlas Balance/backend/tests/GestionCaja.API.Tests/UserStateMiddlewareTests.cs create mode 100644 Documentacion/SEGURIDAD_AUDITORIA_V-01.03.md create mode 100644 Documentacion/Versiones/v-01.03.md diff --git a/Atlas Balance/AGENTS.md b/Atlas Balance/AGENTS.md index 5bca826..5dbdd8b 100644 --- a/Atlas Balance/AGENTS.md +++ b/Atlas Balance/AGENTS.md @@ -227,7 +227,7 @@ npm run build # Release Windows x64 cd "Atlas Balance" -powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.02 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.03 # Conectar a PostgreSQL psql -h localhost -p 5433 -U app_user -d atlas_balance diff --git a/Atlas Balance/CLAUDE.md b/Atlas Balance/CLAUDE.md index 86d7db7..c0b6a8a 100644 --- a/Atlas Balance/CLAUDE.md +++ b/Atlas Balance/CLAUDE.md @@ -227,7 +227,7 @@ npm run build # Release Windows x64 cd "Atlas Balance" -powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.02 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.03 # Conectar a PostgreSQL psql -h localhost -p 5433 -U app_user -d atlas_balance diff --git a/Atlas Balance/Directory.Build.props b/Atlas Balance/Directory.Build.props index a694c6d..aed92e0 100644 --- a/Atlas Balance/Directory.Build.props +++ b/Atlas Balance/Directory.Build.props @@ -2,10 +2,10 @@ Atlas Labs Atlas Balance - 1.2.0 - 1.2.0.0 - 1.2.0.0 - V-01.02 + 1.3.0 + 1.3.0.0 + 1.3.0.0 + V-01.03 false diff --git a/Atlas Balance/README_RELEASE.md b/Atlas Balance/README_RELEASE.md index 9064965..3254788 100644 --- a/Atlas Balance/README_RELEASE.md +++ b/Atlas Balance/README_RELEASE.md @@ -1,4 +1,4 @@ -# Atlas Balance V-01.02 - release Windows x64 +# Atlas Balance V-01.03 - release Windows x64 Este paquete es autonomo para servidor Windows: el frontend ya esta compilado, el backend y Watchdog van publicados self-contained y la base de datos se prepara desde el instalador. diff --git a/Atlas Balance/VERSION b/Atlas Balance/VERSION index d1614fd..12d3cb0 100644 --- a/Atlas Balance/VERSION +++ b/Atlas Balance/VERSION @@ -1 +1 @@ -V-01.02 +V-01.03 diff --git a/Atlas Balance/backend/src/GestionCaja.API/ConfigurationDefaults.cs b/Atlas Balance/backend/src/GestionCaja.API/ConfigurationDefaults.cs index 2b5e95e..3e8553f 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/ConfigurationDefaults.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/ConfigurationDefaults.cs @@ -2,5 +2,40 @@ namespace GestionCaja.API; public static class ConfigurationDefaults { + public const string GitHubOwner = "AtlasLabs797"; + public const string GitHubRepository = "AtlasBalance"; public const string UpdateCheckUrl = "https://github.com/AtlasLabs797/AtlasBalance"; + + public static bool TryNormalizeUpdateCheckUrl(string? configuredUrl, out string normalizedUrl) + { + normalizedUrl = string.IsNullOrWhiteSpace(configuredUrl) + ? UpdateCheckUrl + : configuredUrl.Trim(); + + if (!Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri)) + { + return false; + } + + if (!uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)) + { + var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return segments.Length >= 2 && + segments[0].Equals(GitHubOwner, StringComparison.OrdinalIgnoreCase) && + segments[1].Equals(GitHubRepository, StringComparison.OrdinalIgnoreCase); + } + + if (uri.Host.Equals("api.github.com", StringComparison.OrdinalIgnoreCase)) + { + var expectedPrefix = $"/repos/{GitHubOwner}/{GitHubRepository}/"; + return uri.AbsolutePath.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase); + } + + return false; + } } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs b/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs index dc86c0c..df8ee8e 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs @@ -6,6 +6,9 @@ public static class AuditActions public const string Logout = "LOGOUT"; public const string LoginFailed = "LOGIN_FAILED"; public const string AccountLocked = "ACCOUNT_LOCKED"; + public const string PasswordChanged = "PASSWORD_CHANGED"; + public const string PasswordReset = "PASSWORD_RESET"; + public const string RefreshTokenReuseDetected = "REFRESH_TOKEN_REUSE_DETECTED"; public const string CreateUsuario = "CREATE_USUARIO"; public const string UpdateUsuario = "UPDATE_USUARIO"; public const string DeleteUsuario = "DELETE_USUARIO"; diff --git a/Atlas Balance/backend/src/GestionCaja.API/Constants/AuthClaimNames.cs b/Atlas Balance/backend/src/GestionCaja.API/Constants/AuthClaimNames.cs new file mode 100644 index 0000000..8f890f6 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Constants/AuthClaimNames.cs @@ -0,0 +1,7 @@ +namespace GestionCaja.API.Constants; + +public static class AuthClaimNames +{ + public const string SecurityStamp = "security_stamp"; + public const string PasswordChangedAt = "password_changed_at"; +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Constants/SecurityPolicy.cs b/Atlas Balance/backend/src/GestionCaja.API/Constants/SecurityPolicy.cs new file mode 100644 index 0000000..f2d584c --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Constants/SecurityPolicy.cs @@ -0,0 +1,44 @@ +namespace GestionCaja.API.Constants; + +public static class SecurityPolicy +{ + public const int MinPasswordLength = 12; + + private static readonly HashSet CommonPasswords = new(StringComparer.OrdinalIgnoreCase) + { + "admin", + "admin123", + "admin1234", + "atlasbalance", + "changeme", + "password", + "password123", + "qwerty123", + "welcome123" + }; + + public static bool TryValidatePassword(string? password, out string error) + { + if (string.IsNullOrWhiteSpace(password) || password.Length < MinPasswordLength) + { + error = $"La contraseña debe tener al menos {MinPasswordLength} caracteres"; + return false; + } + + var normalized = password.Trim(); + if (CommonPasswords.Contains(normalized)) + { + error = "La contraseña es demasiado comun"; + return false; + } + + if (normalized.Distinct().Count() == 1) + { + error = "La contraseña no puede repetir un solo caracter"; + return false; + } + + error = string.Empty; + return true; + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ConfiguracionController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ConfiguracionController.cs index ff1198f..de0a744 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ConfiguracionController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ConfiguracionController.cs @@ -75,11 +75,41 @@ public async Task Get(CancellationToken cancellationToken) [HttpPut] public async Task Update([FromBody] UpdateConfiguracionRequest request, CancellationToken cancellationToken) { + if (request is null) + { + return BadRequest(new { error = "Request invalido." }); + } + + if (request.Smtp is null || request.General is null || request.Dashboard is null) + { + return BadRequest(new { error = "Configuracion incompleta." }); + } + if (request.Smtp.Port <= 0 || request.Smtp.Port > 65535) { return BadRequest(new { error = "Puerto SMTP inválido." }); } + if (!IsValidAppBaseUrl(request.General.AppBaseUrl)) + { + return BadRequest(new { error = "La URL base debe ser absoluta y usar http o https." }); + } + + if (!ConfigurationDefaults.TryNormalizeUpdateCheckUrl(request.General.AppUpdateCheckUrl, out var updateCheckUrl)) + { + return BadRequest(new { error = "La URL de actualizaciones debe apuntar al repositorio oficial de Atlas Balance en GitHub por HTTPS." }); + } + + if (!IsSafeAbsoluteDirectory(request.General.BackupPath)) + { + return BadRequest(new { error = "La ruta de backups debe ser absoluta y no contener traversal." }); + } + + if (!IsSafeAbsoluteDirectory(request.General.ExportPath)) + { + return BadRequest(new { error = "La ruta de exportaciones debe ser absoluta y no contener traversal." }); + } + var userId = GetCurrentUserId(); var now = DateTime.UtcNow; var config = await _dbContext.Configuraciones.ToListAsync(cancellationToken); @@ -95,7 +125,7 @@ public async Task Update([FromBody] UpdateConfiguracionRequest re Upsert(config, "smtp_from", request.Smtp.From.Trim(), userId, now); Upsert(config, "app_base_url", request.General.AppBaseUrl.Trim(), userId, now); - Upsert(config, "app_update_check_url", request.General.AppUpdateCheckUrl.Trim(), userId, now); + Upsert(config, "app_update_check_url", updateCheckUrl, userId, now); Upsert(config, "backup_path", request.General.BackupPath.Trim(), userId, now); Upsert(config, "export_path", request.General.ExportPath.Trim(), userId, now); var exchangeApiKey = request.Exchange?.ApiKey; @@ -205,6 +235,54 @@ private static int ParseInt(string value, int fallback) return int.TryParse(value, out var parsed) ? parsed : fallback; } + private static bool IsValidAppBaseUrl(string? value) + { + if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri)) + { + return false; + } + + return uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSafeAbsoluteDirectory(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var trimmed = value.Trim(); + if (trimmed.Contains("..", StringComparison.Ordinal)) + { + return false; + } + + if (!Path.IsPathRooted(trimmed) && !LooksLikeWindowsRootedPath(trimmed)) + { + return false; + } + + try + { + var fullPath = Path.GetFullPath(trimmed); + return !string.IsNullOrWhiteSpace(fullPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + } + + private static bool LooksLikeWindowsRootedPath(string value) + { + return value.Length >= 3 && + char.IsLetter(value[0]) && + value[1] == ':' && + (value[2] == '\\' || value[2] == '/'); + } + private static Dictionary RedactSensitiveConfig(IReadOnlyDictionary source) { return source.ToDictionary( diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExportacionesController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExportacionesController.cs index bef5389..a0e5e63 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExportacionesController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExportacionesController.cs @@ -213,6 +213,11 @@ private static bool IsAllowedExportFile(string filePath, string exportRoot) return false; } + if (!IsExplicitlyRooted(exportRoot)) + { + return false; + } + try { var fullFilePath = Path.GetFullPath(filePath); @@ -231,4 +236,13 @@ private static string EnsureTrailingSeparator(string path) ? path : $"{path}{Path.DirectorySeparatorChar}"; } + + private static bool IsExplicitlyRooted(string path) + { + return Path.IsPathRooted(path) || + (path.Length >= 3 && + char.IsLetter(path[0]) && + path[1] == ':' && + (path[2] == '\\' || path[2] == '/')); + } } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs index 7126f7d..39fa3aa 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs @@ -588,9 +588,7 @@ private async Task> GetAllowedAccountIds(Actor actor, Cancellation if (actor.IsAdmin) return [.. await _db.Cuentas.Select(c => c.Id).ToListAsync(ct)]; var perms = await _db.PermisosUsuario.Where(p => p.UsuarioId == actor.Id).ToListAsync(ct); if (!perms.Any()) return []; - if (perms.Any(p => p.CuentaId is null && p.TitularId is null && - (p.PuedeAgregarLineas || p.PuedeEditarLineas || p.PuedeEliminarLineas || - p.PuedeImportar || p.PuedeVerDashboard))) + if (perms.Any(p => p.CuentaId is null && p.TitularId is null && GrantsDataAccess(p))) { return [.. await _db.Cuentas.Select(c => c.Id).ToListAsync(ct)]; } @@ -616,9 +614,7 @@ private async Task CanViewTitular(Actor actor, Guid titularId, Cancellatio return false; } - if (perms.Any(p => p.CuentaId is null && p.TitularId is null && - (p.PuedeAgregarLineas || p.PuedeEditarLineas || p.PuedeEliminarLineas || - p.PuedeImportar || p.PuedeVerDashboard))) + if (perms.Any(p => p.CuentaId is null && p.TitularId is null && GrantsDataAccess(p))) { return true; } @@ -638,6 +634,9 @@ private async Task CanViewTitular(Actor actor, Guid titularId, Cancellatio await _db.Cuentas.AnyAsync(c => c.TitularId == titularId && permittedCuentaIds.Contains(c.Id), ct); } + private static bool GrantsDataAccess(PermisoUsuario permiso) => + permiso.PuedeAgregarLineas || permiso.PuedeEditarLineas || permiso.PuedeEliminarLineas || permiso.PuedeImportar; + private async Task GetPermission(Actor actor, Cuenta cuenta, CancellationToken ct) { if (actor.IsAdmin) return new Perm { CanAdd = true, CanEdit = true, CanDelete = true, EditableCols = null }; diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs index e56410b..363687d 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs @@ -384,9 +384,9 @@ public async Task Crear([FromBody] CreateUsuarioRequest request, return BadRequest(new { error = "Email, nombre y password son obligatorios" }); } - if (request.Password.Length < 8) + if (!SecurityPolicy.TryValidatePassword(request.Password, out var passwordError)) { - return BadRequest(new { error = "Password mínimo 8 caracteres" }); + return BadRequest(new { error = passwordError }); } var normalizedEmail = NormalizeEmail(request.Email); @@ -411,7 +411,9 @@ public async Task Crear([FromBody] CreateUsuarioRequest request, Rol = request.Rol, Activo = request.Activo, PrimerLogin = request.PrimerLogin, - FechaCreacion = DateTime.UtcNow + FechaCreacion = DateTime.UtcNow, + SecurityStamp = UserSessionState.CreateSecurityStamp(), + PasswordChangedAt = DateTime.UtcNow }; _dbContext.Usuarios.Add(usuario); @@ -491,19 +493,33 @@ public async Task Actualizar(Guid id, [FromBody] UpdateUsuarioReq permisos = await LoadPermisosAuditSnapshotAsync(id, cancellationToken) }; + var shouldRevokeForDeactivation = usuario.Activo && !request.Activo; + usuario.Email = normalizedEmail; usuario.NombreCompleto = request.NombreCompleto.Trim(); usuario.Rol = request.Rol; usuario.Activo = request.Activo; usuario.PrimerLogin = request.PrimerLogin; + var passwordChanged = false; + var revokedRefreshTokens = 0; if (!string.IsNullOrWhiteSpace(request.PasswordNueva)) { - if (request.PasswordNueva.Length < 8) + if (!SecurityPolicy.TryValidatePassword(request.PasswordNueva, out var resetPasswordError)) { - return BadRequest(new { error = "La nueva contraseña debe tener al menos 8 caracteres" }); + return BadRequest(new { error = resetPasswordError }); } + var now = DateTime.UtcNow; usuario.PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.PasswordNueva, workFactor: 12); + UserSessionState.RotateAfterPasswordChange(usuario, now); + revokedRefreshTokens = await RevokeActiveRefreshTokensAsync(usuario.Id, now, cancellationToken); + passwordChanged = true; + } + else if (shouldRevokeForDeactivation) + { + var now = DateTime.UtcNow; + UserSessionState.RotateSecurityStamp(usuario); + revokedRefreshTokens = await RevokeActiveRefreshTokensAsync(usuario.Id, now, cancellationToken); } var normalizedEmails = NormalizeEmails(request.Emails); @@ -525,6 +541,18 @@ public async Task Actualizar(Guid id, [FromBody] UpdateUsuarioReq await _auditService.LogAsync(GetCurrentUserId(), AuditActions.UpdateUsuario, "USUARIOS", usuario.Id, HttpContext, JsonSerializer.Serialize(new { before, after }), cancellationToken); + if (passwordChanged) + { + await _auditService.LogAsync( + GetCurrentUserId(), + AuditActions.PasswordReset, + "USUARIOS", + usuario.Id, + HttpContext, + JsonSerializer.Serialize(new { password_reset = true, refresh_tokens_revocados = revokedRefreshTokens }), + cancellationToken); + } + if (JsonSerializer.Serialize(before.permisos) != JsonSerializer.Serialize(after.permisos)) { await _auditService.LogAsync( @@ -562,9 +590,12 @@ public async Task Eliminar(Guid id, CancellationToken cancellatio usuario.DeletedById }; + var deletedAt = DateTime.UtcNow; usuario.Activo = false; - usuario.DeletedAt = DateTime.UtcNow; + usuario.DeletedAt = deletedAt; usuario.DeletedById = actorId; + UserSessionState.RotateSecurityStamp(usuario); + var revokedRefreshTokens = await RevokeActiveRefreshTokensAsync(usuario.Id, deletedAt, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); var after = new @@ -575,7 +606,7 @@ public async Task Eliminar(Guid id, CancellationToken cancellatio }; await _auditService.LogAsync(actorId, AuditActions.DeleteUsuario, "USUARIOS", usuario.Id, HttpContext, - JsonSerializer.Serialize(new { before, after }), cancellationToken); + JsonSerializer.Serialize(new { before, after, refresh_tokens_revocados = revokedRefreshTokens }), cancellationToken); return Ok(new { message = "Usuario eliminado" }); } @@ -599,6 +630,7 @@ public async Task Restaurar(Guid id, CancellationToken cancellati usuario.DeletedAt = null; usuario.DeletedById = null; usuario.Activo = true; + UserSessionState.RotateSecurityStamp(usuario); await _dbContext.SaveChangesAsync(cancellationToken); @@ -686,6 +718,20 @@ private async Task PromoteFirstEmailAsync(Guid usuarioId, CancellationToken canc await _dbContext.SaveChangesAsync(cancellationToken); } + private async Task RevokeActiveRefreshTokensAsync(Guid usuarioId, DateTime revokedAt, CancellationToken cancellationToken) + { + var activeRefreshTokens = await _dbContext.RefreshTokens + .Where(rt => rt.UsuarioId == usuarioId && rt.RevocadoEn == null && rt.ExpiraEn > revokedAt) + .ToListAsync(cancellationToken); + + foreach (var refreshToken in activeRefreshTokens) + { + refreshToken.RevocadoEn = revokedAt; + } + + return activeRefreshTokens.Count; + } + private async Task> LoadPermisosAsync(Guid usuarioId, CancellationToken cancellationToken) { var permisos = await _dbContext.PermisosUsuario diff --git a/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs b/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs index f2a23e3..5f55784 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs @@ -51,6 +51,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.Rol); entity.HasIndex(e => e.Activo); entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()"); + entity.Property(e => e.SecurityStamp).HasMaxLength(64).IsRequired(); }); modelBuilder.Entity(entity => diff --git a/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs b/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs index 6b8ab07..881eee2 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs @@ -1,4 +1,6 @@ using GestionCaja.API.Models; +using GestionCaja.API.Constants; +using GestionCaja.API.Services; using Microsoft.EntityFrameworkCore; namespace GestionCaja.API.Data; @@ -41,7 +43,9 @@ public static void Initialize(AppDbContext context, IConfiguration? configuratio Rol = RolUsuario.ADMIN, Activo = true, PrimerLogin = true, - FechaCreacion = now + FechaCreacion = now, + SecurityStamp = UserSessionState.CreateSecurityStamp(), + PasswordChangedAt = now }); context.DivisasActivas.AddRange( @@ -65,7 +69,7 @@ public static void Initialize(AppDbContext context, IConfiguration? configuratio ["backup_retention_weeks"] = ("6", "int", "Semanas de retención de backups"), ["backup_path"] = ("C:/AtlasBalance/backups", "string", "Ruta de almacenamiento de backups"), ["export_path"] = ("C:/AtlasBalance/exports", "string", "Ruta de exportaciones"), - ["app_version"] = ("V-01.02", "string", "Versión instalada"), + ["app_version"] = ("V-01.03", "string", "Versión instalada"), ["app_update_check_url"] = (ConfigurationDefaults.UpdateCheckUrl, "string", "URL del servidor de actualizaciones"), ["smtp_host"] = ("", "string", "Host SMTP"), ["smtp_port"] = ("587", "int", "Puerto SMTP"), @@ -112,14 +116,13 @@ private static string ResolveSeedAdminPassword(IConfiguration? configuration, bo throw new InvalidOperationException("SeedAdmin:Password must be configured before first startup."); } - if (configuredPassword.Length < 8) + if (!SecurityPolicy.TryValidatePassword(configuredPassword, out var passwordError)) { - throw new InvalidOperationException("SeedAdmin:Password must contain at least 8 characters."); + throw new InvalidOperationException($"SeedAdmin:Password is not valid: {passwordError}."); } if (!isDevelopment && - (configuredPassword.Length < 12 || - LooksLikePlaceholder(configuredPassword))) + LooksLikePlaceholder(configuredPassword)) { throw new InvalidOperationException("SeedAdmin:Password must be a real non-default production password."); } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs b/Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs index 1dcaea3..e7f5b86 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs @@ -6,6 +6,7 @@ using GestionCaja.API.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; namespace GestionCaja.API.Middleware; @@ -17,6 +18,7 @@ public static class IntegrationHttpContextItemKeys public sealed class IntegrationAuthMiddleware { private const string RedactedMarker = "[REDACTED]"; + private const int DefaultInvalidAuthLimitPerMinute = 30; private static readonly HashSet SensitiveQueryKeys = new(StringComparer.OrdinalIgnoreCase) { @@ -37,12 +39,17 @@ public sealed class IntegrationAuthMiddleware private readonly IMemoryCache _cache; private readonly IClock _clock; private readonly object _rateLimitLock = new(); + private readonly object _invalidAuthRateLimitLock = new(); + private readonly int _invalidAuthLimitPerMinute; - public IntegrationAuthMiddleware(RequestDelegate next, IMemoryCache cache, IClock clock) + public IntegrationAuthMiddleware(RequestDelegate next, IMemoryCache cache, IClock clock, IConfiguration? configuration = null) { _next = next; _cache = cache; _clock = clock; + _invalidAuthLimitPerMinute = Math.Max( + 1, + configuration?.GetValue("IntegrationSecurity:InvalidAuthLimitPerMinute") ?? DefaultInvalidAuthLimitPerMinute); } public async Task InvokeAsync(HttpContext context, AppDbContext dbContext, IIntegrationTokenService integrationTokenService) @@ -55,14 +62,24 @@ public async Task InvokeAsync(HttpContext context, AppDbContext dbContext, IInte var authHeader = context.Request.Headers.Authorization.ToString(); var plainToken = ExtractBearerToken(authHeader); + if (IsInvalidAuthRateLimited(context)) + { + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.Response.WriteAsJsonAsync(IntegrationApiResponses.Failure("RATE_LIMITED: Demasiados intentos con token invalido")); + return; + } + var integrationToken = await integrationTokenService.ValidateActiveTokenAsync(plainToken, CancellationToken.None); if (integrationToken is null) { + RecordInvalidAuthFailure(context); context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsJsonAsync(IntegrationApiResponses.Failure("UNAUTHORIZED: Token de integracion invalido o revocado")); return; } + ClearInvalidAuthFailures(context); + var limit = await ResolveRateLimitAsync(dbContext, CancellationToken.None); if (!TryConsumeRateLimit(integrationToken.Id, limit)) { @@ -200,4 +217,38 @@ private bool TryConsumeRateLimit(Guid tokenId, int limit) } } + private bool IsInvalidAuthRateLimited(HttpContext context) + { + var key = BuildInvalidAuthRateLimitKey(context); + lock (_invalidAuthRateLimitLock) + { + return _cache.TryGetValue(key, out var count) && + count >= _invalidAuthLimitPerMinute; + } + } + + private void RecordInvalidAuthFailure(HttpContext context) + { + var key = BuildInvalidAuthRateLimitKey(context); + lock (_invalidAuthRateLimitLock) + { + var count = _cache.Get(key) + 1; + _cache.Set(key, count, TimeSpan.FromMinutes(2)); + } + } + + private void ClearInvalidAuthFailures(HttpContext context) + { + _cache.Remove(BuildInvalidAuthRateLimitKey(context)); + } + + private string BuildInvalidAuthRateLimitKey(HttpContext context) + { + var ipAddress = context.Connection.RemoteIpAddress?.ToString(); + var client = string.IsNullOrWhiteSpace(ipAddress) ? "unknown" : ipAddress; + var now = _clock.UtcNow; + var currentMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc); + return $"integration:invalid-auth:{client}:{currentMinute:yyyyMMddHHmm}"; + } + } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Middleware/UserStateMiddleware.cs b/Atlas Balance/backend/src/GestionCaja.API/Middleware/UserStateMiddleware.cs index 18418c1..844d42f 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Middleware/UserStateMiddleware.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Middleware/UserStateMiddleware.cs @@ -1,4 +1,7 @@ using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using GestionCaja.API.Constants; using GestionCaja.API.Data; using GestionCaja.API.Models; using Microsoft.EntityFrameworkCore; @@ -57,6 +60,12 @@ public async Task InvokeAsync(HttpContext context, AppDbContext dbContext) return; } + if (!HasValidSecurityStamp(context.User, usuario)) + { + await RejectAsync(context, "La sesion ya no es valida"); + return; + } + context.Items[HttpContextItemKeys.CurrentUsuario] = usuario; context.User = BuildPrincipal(usuario, context.User.Identity?.AuthenticationType); @@ -86,12 +95,27 @@ private static ClaimsPrincipal BuildPrincipal(Usuario usuario, string? authentic new Claim(ClaimTypes.NameIdentifier, usuario.Id.ToString()), new Claim(ClaimTypes.Email, usuario.Email), new Claim(ClaimTypes.Name, usuario.NombreCompleto), - new Claim(ClaimTypes.Role, usuario.Rol.ToString()) + new Claim(ClaimTypes.Role, usuario.Rol.ToString()), + new Claim(AuthClaimNames.SecurityStamp, usuario.SecurityStamp) }, authenticationType ?? "JwtCookie"); return new ClaimsPrincipal(identity); } + private static bool HasValidSecurityStamp(ClaimsPrincipal principal, Usuario usuario) + { + var tokenStamp = principal.FindFirstValue(AuthClaimNames.SecurityStamp); + if (string.IsNullOrWhiteSpace(tokenStamp) || string.IsNullOrWhiteSpace(usuario.SecurityStamp)) + { + return false; + } + + var tokenBytes = Encoding.UTF8.GetBytes(tokenStamp); + var userBytes = Encoding.UTF8.GetBytes(usuario.SecurityStamp); + return tokenBytes.Length == userBytes.Length && + CryptographicOperations.FixedTimeEquals(tokenBytes, userBytes); + } + private static bool TryGetUserId(ClaimsPrincipal user, out Guid userId) { var raw = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub"); diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425081244_UserSessionHardening.Designer.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425081244_UserSessionHardening.Designer.cs new file mode 100644 index 0000000..3b1f716 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425081244_UserSessionHardening.Designer.cs @@ -0,0 +1,1534 @@ +// +using System; +using System.Net; +using GestionCaja.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GestionCaja.API.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260425081244_UserSessionHardening")] + partial class UserSessionHardening + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_proceso", new[] { "pending", "success", "failed" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_token_integracion", new[] { "activo", "revocado" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "fuente_tipo_cambio", new[] { "api", "manual" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "rol_usuario", new[] { "admin", "gerente", "empleado_ultra", "empleado_plus", "empleado" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_proceso", new[] { "auto", "manual" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_titular", new[] { "empresa", "particular" }); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaDestinatario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlertaId") + .HasColumnType("uuid") + .HasColumnName("alerta_id"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_alerta_destinatarios"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_alerta_destinatarios_usuario_id"); + + b.HasIndex("AlertaId", "UsuarioId") + .IsUnique() + .HasDatabaseName("ix_alerta_destinatarios_alerta_id_usuario_id"); + + b.ToTable("ALERTA_DESTINATARIOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaSaldo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaUltimaAlerta") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_alerta"); + + b.Property("SaldoMinimo") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("saldo_minimo"); + + b.HasKey("Id") + .HasName("pk_alertas_saldo"); + + b.HasIndex("CuentaId") + .IsUnique() + .HasDatabaseName("ix_alertas_saldo_cuenta_id") + .HasFilter("\"cuenta_id\" IS NOT NULL"); + + b.ToTable("ALERTAS_SALDO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Auditoria", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CeldaReferencia") + .HasColumnType("text") + .HasColumnName("celda_referencia"); + + b.Property("ColumnaNombre") + .HasColumnType("text") + .HasColumnName("columna_nombre"); + + b.Property("DetallesJson") + .HasColumnType("jsonb") + .HasColumnName("detalles_json"); + + b.Property("EntidadId") + .HasColumnType("uuid") + .HasColumnName("entidad_id"); + + b.Property("EntidadTipo") + .HasColumnType("text") + .HasColumnName("entidad_tipo"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("TipoAccion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tipo_accion"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.Property("ValorAnterior") + .HasColumnType("text") + .HasColumnName("valor_anterior"); + + b.Property("ValorNuevo") + .HasColumnType("text") + .HasColumnName("valor_nuevo"); + + b.HasKey("Id") + .HasName("pk_auditorias"); + + b.HasIndex("EntidadId") + .HasDatabaseName("ix_auditorias_entidad_id"); + + b.HasIndex("Timestamp") + .HasDatabaseName("ix_auditorias_timestamp"); + + b.HasIndex("TipoAccion") + .HasDatabaseName("ix_auditorias_tipo_accion"); + + b.HasIndex("UsuarioId", "Timestamp") + .HasDatabaseName("ix_auditorias_usuario_id_timestamp"); + + b.ToTable("AUDITORIAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AuditoriaIntegracion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CodigoRespuesta") + .HasColumnType("integer") + .HasColumnName("codigo_respuesta"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("Metodo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("metodo"); + + b.Property("Parametros") + .HasColumnType("jsonb") + .HasColumnName("parametros"); + + b.Property("TiempoEjecucionMs") + .HasColumnType("integer") + .HasColumnName("tiempo_ejecucion_ms"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("TokenId") + .HasColumnType("uuid") + .HasColumnName("token_id"); + + b.HasKey("Id") + .HasName("pk_auditoria_integraciones"); + + b.HasIndex("CodigoRespuesta") + .HasDatabaseName("ix_auditoria_integraciones_codigo_respuesta"); + + b.HasIndex("Timestamp") + .HasDatabaseName("ix_auditoria_integraciones_timestamp"); + + b.HasIndex("TokenId") + .HasDatabaseName("ix_auditoria_integraciones_token_id"); + + b.ToTable("AUDITORIA_INTEGRACIONES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Backup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("IniciadoPorId") + .HasColumnType("uuid") + .HasColumnName("iniciado_por_id"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("RutaArchivo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ruta_archivo"); + + b.Property("TamanioBytes") + .HasColumnType("bigint") + .HasColumnName("tamanio_bytes"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_backups"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_backups_deleted_by_id"); + + b.HasIndex("IniciadoPorId") + .HasDatabaseName("ix_backups_iniciado_por_id"); + + b.ToTable("BACKUPS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Configuracion", b => + { + b.Property("Clave") + .HasColumnType("text") + .HasColumnName("clave"); + + b.Property("Descripcion") + .HasColumnType("text") + .HasColumnName("descripcion"); + + b.Property("FechaModificacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_modificacion"); + + b.Property("Tipo") + .HasColumnType("text") + .HasColumnName("tipo"); + + b.Property("UsuarioModificacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_modificacion_id"); + + b.Property("Valor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("valor"); + + b.HasKey("Clave") + .HasName("pk_configuracion"); + + b.HasIndex("UsuarioModificacionId") + .HasDatabaseName("ix_configuracion_usuario_modificacion_id"); + + b.ToTable("CONFIGURACION", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Cuenta", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("BancoNombre") + .HasColumnType("text") + .HasColumnName("banco_nombre"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Divisa") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa"); + + b.Property("EsEfectivo") + .HasColumnType("boolean") + .HasColumnName("es_efectivo"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FormatoId") + .HasColumnType("uuid") + .HasColumnName("formato_id"); + + b.Property("Iban") + .HasColumnType("text") + .HasColumnName("iban"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("NumeroCuenta") + .HasColumnType("text") + .HasColumnName("numero_cuenta"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.HasKey("Id") + .HasName("pk_cuentas"); + + b.HasIndex("Activa") + .HasDatabaseName("ix_cuentas_activa"); + + b.HasIndex("DeletedAt") + .HasDatabaseName("ix_cuentas_deleted_at"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_cuentas_deleted_by_id"); + + b.HasIndex("Divisa") + .HasDatabaseName("ix_cuentas_divisa"); + + b.HasIndex("EsEfectivo") + .HasDatabaseName("ix_cuentas_es_efectivo"); + + b.HasIndex("FormatoId") + .HasDatabaseName("ix_cuentas_formato_id"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_cuentas_titular_id"); + + b.ToTable("CUENTAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.DivisaActiva", b => + { + b.Property("Codigo") + .HasColumnType("text") + .HasColumnName("codigo"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("EsBase") + .HasColumnType("boolean") + .HasColumnName("es_base"); + + b.Property("Nombre") + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Simbolo") + .HasColumnType("text") + .HasColumnName("simbolo"); + + b.HasKey("Codigo") + .HasName("pk_divisas_activas"); + + b.ToTable("DIVISAS_ACTIVAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Exportacion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaExportacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_exportacion"); + + b.Property("IniciadoPorId") + .HasColumnType("uuid") + .HasColumnName("iniciado_por_id"); + + b.Property("RutaArchivo") + .HasColumnType("text") + .HasColumnName("ruta_archivo"); + + b.Property("TamanioBytes") + .HasColumnType("bigint") + .HasColumnName("tamanio_bytes"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_exportaciones"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_exportaciones_cuenta_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_exportaciones_deleted_by_id"); + + b.HasIndex("IniciadoPorId") + .HasDatabaseName("ix_exportaciones_iniciado_por_id"); + + b.ToTable("EXPORTACIONES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Extracto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Checked") + .HasColumnType("boolean") + .HasColumnName("checked"); + + b.Property("CheckedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checked_at"); + + b.Property("CheckedById") + .HasColumnType("uuid") + .HasColumnName("checked_by_id"); + + b.Property("Comentarios") + .HasColumnType("text") + .HasColumnName("comentarios"); + + b.Property("Concepto") + .HasColumnType("text") + .HasColumnName("concepto"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Fecha") + .HasColumnType("date") + .HasColumnName("fecha"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaModificacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_modificacion"); + + b.Property("FilaNumero") + .HasColumnType("integer") + .HasColumnName("fila_numero"); + + b.Property("Flagged") + .HasColumnType("boolean") + .HasColumnName("flagged"); + + b.Property("FlaggedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("flagged_at"); + + b.Property("FlaggedById") + .HasColumnType("uuid") + .HasColumnName("flagged_by_id"); + + b.Property("FlaggedNota") + .HasColumnType("text") + .HasColumnName("flagged_nota"); + + b.Property("Monto") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("monto"); + + b.Property("Saldo") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("saldo"); + + b.Property("UsuarioCreacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_creacion_id"); + + b.Property("UsuarioModificacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_modificacion_id"); + + b.HasKey("Id") + .HasName("pk_extractos"); + + b.HasIndex("Checked") + .HasDatabaseName("ix_extractos_checked"); + + b.HasIndex("CheckedById") + .HasDatabaseName("ix_extractos_checked_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_extractos_deleted_by_id"); + + b.HasIndex("Fecha") + .HasDatabaseName("ix_extractos_fecha"); + + b.HasIndex("Flagged") + .HasDatabaseName("ix_extractos_flagged"); + + b.HasIndex("FlaggedById") + .HasDatabaseName("ix_extractos_flagged_by_id"); + + b.HasIndex("UsuarioCreacionId") + .HasDatabaseName("ix_extractos_usuario_creacion_id"); + + b.HasIndex("UsuarioModificacionId") + .HasDatabaseName("ix_extractos_usuario_modificacion_id"); + + b.HasIndex("CuentaId", "DeletedAt") + .HasDatabaseName("ix_extractos_cuenta_id_deleted_at"); + + b.HasIndex("CuentaId", "Fecha") + .HasDatabaseName("ix_extractos_cuenta_id_fecha"); + + b.HasIndex("CuentaId", "FilaNumero") + .IsUnique() + .HasDatabaseName("ix_extractos_cuenta_id_fila_numero"); + + b.ToTable("EXTRACTOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.ExtractoColumnaExtra", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExtractoId") + .HasColumnType("uuid") + .HasColumnName("extracto_id"); + + b.Property("NombreColumna") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre_columna"); + + b.Property("Valor") + .HasColumnType("text") + .HasColumnName("valor"); + + b.HasKey("Id") + .HasName("pk_extractos_columnas_extra"); + + b.HasIndex("ExtractoId") + .HasDatabaseName("ix_extractos_columnas_extra_extracto_id"); + + b.HasIndex("NombreColumna") + .HasDatabaseName("ix_extractos_columnas_extra_nombre_columna"); + + b.ToTable("EXTRACTOS_COLUMNAS_EXTRA", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.FormatoImportacion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activo") + .HasColumnType("boolean") + .HasColumnName("activo"); + + b.Property("BancoNombre") + .HasColumnType("text") + .HasColumnName("banco_nombre"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Divisa") + .HasColumnType("text") + .HasColumnName("divisa"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("MapeoJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("mapeo_json"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("UsuarioCreadorId") + .HasColumnType("uuid") + .HasColumnName("usuario_creador_id"); + + b.HasKey("Id") + .HasName("pk_formatos_importacion"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_formatos_importacion_deleted_by_id"); + + b.HasIndex("UsuarioCreadorId") + .HasDatabaseName("ix_formatos_importacion_usuario_creador_id"); + + b.ToTable("FORMATOS_IMPORTACION", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccesoTipo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("acceso_tipo"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.Property("TokenId") + .HasColumnType("uuid") + .HasColumnName("token_id"); + + b.HasKey("Id") + .HasName("pk_integration_permissions"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_integration_permissions_cuenta_id"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_integration_permissions_titular_id"); + + b.HasIndex("TokenId") + .HasDatabaseName("ix_integration_permissions_token_id"); + + b.ToTable("INTEGRATION_PERMISSIONS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Descripcion") + .HasColumnType("text") + .HasColumnName("descripcion"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaRevocacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_revocacion"); + + b.Property("FechaUltimaUso") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_uso"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("PermisoEscritura") + .HasColumnType("boolean") + .HasColumnName("permiso_escritura"); + + b.Property("PermisoLectura") + .HasColumnType("boolean") + .HasColumnName("permiso_lectura"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tipo"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token_hash"); + + b.Property("UsuarioCreadorId") + .HasColumnType("uuid") + .HasColumnName("usuario_creador_id"); + + b.HasKey("Id") + .HasName("pk_integration_tokens"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_integration_tokens_deleted_by_id"); + + b.HasIndex("Estado") + .HasDatabaseName("ix_integration_tokens_estado"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_integration_tokens_token_hash"); + + b.HasIndex("UsuarioCreadorId") + .HasDatabaseName("ix_integration_tokens_usuario_creador_id"); + + b.ToTable("INTEGRATION_TOKENS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.NotificacionAdmin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DetallesJson") + .HasColumnType("jsonb") + .HasColumnName("detalles_json"); + + b.Property("Fecha") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha"); + + b.Property("Leida") + .HasColumnType("boolean") + .HasColumnName("leida"); + + b.Property("Mensaje") + .HasColumnType("text") + .HasColumnName("mensaje"); + + b.Property("Tipo") + .HasColumnType("text") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_notificaciones_admin"); + + b.ToTable("NOTIFICACIONES_ADMIN", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PermisoUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("PuedeAgregarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_agregar_lineas"); + + b.Property("PuedeEditarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_editar_lineas"); + + b.Property("PuedeEliminarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_eliminar_lineas"); + + b.Property("PuedeImportar") + .HasColumnType("boolean") + .HasColumnName("puede_importar"); + + b.Property("PuedeVerDashboard") + .HasColumnType("boolean") + .HasColumnName("puede_ver_dashboard"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_permisos_usuario"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_permisos_usuario_cuenta_id"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_permisos_usuario_titular_id"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_permisos_usuario_usuario_id"); + + b.HasIndex("UsuarioId", "CuentaId") + .HasDatabaseName("ix_permisos_usuario_usuario_id_cuenta_id"); + + b.ToTable("PERMISOS_USUARIO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PreferenciaUsuarioCuenta", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ColumnasEditables") + .HasColumnType("jsonb") + .HasColumnName("columnas_editables"); + + b.Property("ColumnasVisibles") + .HasColumnType("jsonb") + .HasColumnName("columnas_visibles"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_preferencias_usuario_cuenta"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_preferencias_usuario_cuenta_cuenta_id"); + + b.HasIndex("UsuarioId") + .IsUnique() + .HasDatabaseName("ix_preferencias_usuario_cuenta_usuario_id") + .HasFilter("\"cuenta_id\" IS NULL"); + + b.HasIndex("UsuarioId", "CuentaId") + .IsUnique() + .HasDatabaseName("ix_preferencias_usuario_cuenta_usuario_id_cuenta_id") + .HasFilter("\"cuenta_id\" IS NOT NULL"); + + b.ToTable("PREFERENCIAS_USUARIO_CUENTA", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreadoEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("creado_en"); + + b.Property("ExpiraEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_en"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("ReemplazadoPor") + .HasColumnType("text") + .HasColumnName("reemplazado_por"); + + b.Property("RevocadoEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("revocado_en"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token_hash"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("ExpiraEn") + .HasDatabaseName("ix_refresh_tokens_expira_en"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_hash"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_refresh_tokens_usuario_id"); + + b.ToTable("REFRESH_TOKENS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.TipoCambio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DivisaDestino") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa_destino"); + + b.Property("DivisaOrigen") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa_origen"); + + b.Property("FechaActualizacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_actualizacion"); + + b.Property("Fuente") + .HasColumnType("integer") + .HasColumnName("fuente"); + + b.Property("Tasa") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)") + .HasColumnName("tasa"); + + b.HasKey("Id") + .HasName("pk_tipos_cambio"); + + b.HasIndex("DivisaOrigen", "DivisaDestino") + .IsUnique() + .HasDatabaseName("ix_tipos_cambio_divisa_origen_divisa_destino"); + + b.ToTable("TIPOS_CAMBIO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Titular", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContactoEmail") + .HasColumnType("text") + .HasColumnName("contacto_email"); + + b.Property("ContactoTelefono") + .HasColumnType("text") + .HasColumnName("contacto_telefono"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("Identificacion") + .HasColumnType("text") + .HasColumnName("identificacion"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_titulares"); + + b.HasIndex("DeletedAt") + .HasDatabaseName("ix_titulares_deleted_at"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_titulares_deleted_by_id"); + + b.HasIndex("Nombre") + .HasDatabaseName("ix_titulares_nombre"); + + b.HasIndex("Tipo") + .HasDatabaseName("ix_titulares_tipo"); + + b.ToTable("TITULARES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Usuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Activo") + .HasColumnType("boolean") + .HasColumnName("activo"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("FailedLoginAttempts") + .HasColumnType("integer") + .HasColumnName("failed_login_attempts"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaUltimaLogin") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_login"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("NombreCompleto") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre_completo"); + + b.Property("PasswordChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("password_changed_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PrimerLogin") + .HasColumnType("boolean") + .HasColumnName("primer_login"); + + b.Property("Rol") + .HasColumnType("integer") + .HasColumnName("rol"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("security_stamp"); + + b.HasKey("Id") + .HasName("pk_usuarios"); + + b.HasIndex("Activo") + .HasDatabaseName("ix_usuarios_activo"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_usuarios_email"); + + b.HasIndex("Rol") + .HasDatabaseName("ix_usuarios_rol"); + + b.ToTable("USUARIOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.UsuarioEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EsPrincipal") + .HasColumnType("boolean") + .HasColumnName("es_principal"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_usuario_emails"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_usuario_emails_usuario_id"); + + b.ToTable("USUARIO_EMAILS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaDestinatario", b => + { + b.HasOne("GestionCaja.API.Models.AlertaSaldo", null) + .WithMany() + .HasForeignKey("AlertaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_alerta_destinatarios_alertas_saldo_alerta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_alerta_destinatarios_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaSaldo", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_alertas_saldo_cuentas_cuenta_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Auditoria", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_auditorias_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AuditoriaIntegracion", b => + { + b.HasOne("GestionCaja.API.Models.IntegrationToken", null) + .WithMany() + .HasForeignKey("TokenId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_auditoria_integraciones_integration_tokens_token_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Backup", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_backups_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("IniciadoPorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_backups_usuarios_iniciado_por_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Configuracion", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioModificacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_configuracion_usuarios_usuario_modificacion_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Cuenta", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_cuentas_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.FormatoImportacion", null) + .WithMany() + .HasForeignKey("FormatoId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_cuentas_formatos_importacion_formato_id"); + + b.HasOne("GestionCaja.API.Models.Titular", "Titular") + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_cuentas_titulares_titular_id"); + + b.Navigation("Titular"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Exportacion", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_exportaciones_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exportaciones_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("IniciadoPorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exportaciones_usuarios_iniciado_por_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Extracto", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("CheckedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_checked_by_id"); + + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_extractos_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("FlaggedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_flagged_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_usuario_creacion_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioModificacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_usuario_modificacion_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.ExtractoColumnaExtra", b => + { + b.HasOne("GestionCaja.API.Models.Extracto", null) + .WithMany() + .HasForeignKey("ExtractoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_extractos_columnas_extra_extractos_extracto_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.FormatoImportacion", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_formatos_importacion_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreadorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_formatos_importacion_usuarios_usuario_creador_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationPermission", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_permissions_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Titular", null) + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_permissions_titulares_titular_id"); + + b.HasOne("GestionCaja.API.Models.IntegrationToken", null) + .WithMany() + .HasForeignKey("TokenId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_integration_permissions_integration_tokens_token_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationToken", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_tokens_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_integration_tokens_usuarios_usuario_creador_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PermisoUsuario", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_permisos_usuario_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Titular", null) + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_permisos_usuario_titulares_titular_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_permisos_usuario_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PreferenciaUsuarioCuenta", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_preferencias_usuario_cuenta_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_preferencias_usuario_cuenta_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.RefreshToken", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_usuarios_usuario_id"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Titular", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_titulares_usuarios_deleted_by_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.UsuarioEmail", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_usuario_emails_usuarios_usuario_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425081244_UserSessionHardening.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425081244_UserSessionHardening.cs new file mode 100644 index 0000000..e8c39e5 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425081244_UserSessionHardening.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GestionCaja.API.Migrations +{ + /// + public partial class UserSessionHardening : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "password_changed_at", + table: "USUARIOS", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "security_stamp", + table: "USUARIOS", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValueSql: "replace(gen_random_uuid()::text, '-', '')"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "password_changed_at", + table: "USUARIOS"); + + migrationBuilder.DropColumn( + name: "security_stamp", + table: "USUARIOS"); + } + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs index a41b2e7..0477ed7 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs @@ -1171,6 +1171,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("nombre_completo"); + b.Property("PasswordChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("password_changed_at"); + b.Property("PasswordHash") .IsRequired() .HasColumnType("text") @@ -1184,6 +1188,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("rol"); + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("security_stamp"); + b.HasKey("Id") .HasName("pk_usuarios"); diff --git a/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs b/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs index 911d072..0a55546 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs @@ -17,6 +17,8 @@ public class Usuario : ISoftDelete public bool PrimerLogin { get; set; } = true; public DateTime FechaCreacion { get; set; } = DateTime.UtcNow; public DateTime? FechaUltimaLogin { get; set; } + public string SecurityStamp { get; set; } = Guid.NewGuid().ToString("N"); + public DateTime? PasswordChangedAt { get; set; } public int FailedLoginAttempts { get; set; } public DateTime? LockedUntil { get; set; } public DateTime? DeletedAt { get; set; } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs index fcb1114..f467e48 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs @@ -51,7 +51,7 @@ public async Task CheckVersionDisponibleAsync(Cancell .Select(c => c.Valor) .FirstOrDefaultAsync(cancellationToken); - checkUrl = ResolveConfiguredUpdateUrl(checkUrl); + checkUrl = ResolveConfiguredUpdateUrl(checkUrl, _logger); try { @@ -112,7 +112,7 @@ public async Task IniciarActualizacionAsync(string? sourcePath, string? ta .Select(c => c.Valor) .FirstOrDefaultAsync(cancellationToken); - checkUrl = ResolveConfiguredUpdateUrl(checkUrl); + checkUrl = ResolveConfiguredUpdateUrl(checkUrl, _logger); try { @@ -182,11 +182,15 @@ private static string ResolveCurrentVersion() return assembly.GetName().Version?.ToString() ?? "0.0.0"; } - private static string ResolveConfiguredUpdateUrl(string? configuredUrl) + private static string ResolveConfiguredUpdateUrl(string? configuredUrl, ILogger logger) { - return string.IsNullOrWhiteSpace(configuredUrl) - ? ConfigurationDefaults.UpdateCheckUrl - : configuredUrl.Trim(); + if (ConfigurationDefaults.TryNormalizeUpdateCheckUrl(configuredUrl, out var normalizedUrl)) + { + return normalizedUrl; + } + + logger.LogWarning("Update check URL no permitida; se usara el endpoint oficial de Atlas Balance."); + return ConfigurationDefaults.UpdateCheckUrl; } private async Task GetUpdateCheckBodyAsync(string checkUrl, CancellationToken cancellationToken) @@ -260,6 +264,11 @@ private static bool IsAllowedSourcePath(string? sourcePath, string? sourceRoot) return false; } + if (!IsExplicitlyRooted(sourcePath) || !IsExplicitlyRooted(sourceRoot)) + { + return false; + } + try { var fullSource = EnsureTrailingSeparator(Path.GetFullPath(sourcePath)); @@ -279,6 +288,15 @@ private static string EnsureTrailingSeparator(string path) : $"{path}{Path.DirectorySeparatorChar}"; } + private static bool IsExplicitlyRooted(string path) + { + return Path.IsPathRooted(path) || + (path.Length >= 3 && + char.IsLetter(path[0]) && + path[1] == ':' && + (path[2] == '\\' || path[2] == '/')); + } + private readonly record struct UpdateCheckHttpResponse(int StatusCode, bool IsSuccessStatusCode, string Body); private sealed class UpdateCheckPayload diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs index 601dfb8..45e3ba7 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs @@ -1,4 +1,5 @@ using System.IdentityModel.Tokens.Jwt; +using System.Globalization; using System.Security.Claims; using System.Security.Cryptography; using System.Text; @@ -9,6 +10,7 @@ using GestionCaja.API.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Caching.Memory; using Microsoft.IdentityModel.Tokens; namespace GestionCaja.API.Services; @@ -24,18 +26,24 @@ public interface IAuthService public sealed class AuthService : IAuthService { - private const int MaxFailedLoginAttempts = 5; + private const int MaxFailedLoginAttempts = 20; + private const int MaxLoginFailuresPerClientAndEmail = 5; + private static readonly object LoginRateLimitLock = new(); private static readonly TimeSpan LockDuration = TimeSpan.FromMinutes(30); + private static readonly TimeSpan LoginFailureWindow = TimeSpan.FromMinutes(15); + private static readonly IMemoryCache FallbackMemoryCache = new MemoryCache(new MemoryCacheOptions()); private readonly AppDbContext _dbContext; private readonly IConfiguration _configuration; private readonly IAuditService _auditService; + private readonly IMemoryCache _cache; - public AuthService(AppDbContext dbContext, IConfiguration configuration, IAuditService auditService) + public AuthService(AppDbContext dbContext, IConfiguration configuration, IAuditService auditService, IMemoryCache? cache = null) { _dbContext = dbContext; _configuration = configuration; _auditService = auditService; + _cache = cache ?? FallbackMemoryCache; } public async Task LoginAsync(string email, string password, string? ipAddress, CancellationToken cancellationToken) @@ -47,25 +55,44 @@ public async Task LoginAsync(string email, string password, string? var normalizedEmail = email.Trim().ToLowerInvariant(); var now = DateTime.UtcNow; + if (IsLoginThrottled(normalizedEmail, ipAddress)) + { + await _auditService.LogAsync( + null, + AuditActions.LoginFailed, + "USUARIOS", + null, + ipAddress, + JsonSerializer.Serialize(new { email = normalizedEmail, motivo = "rate_limited" }), + cancellationToken); + throw new AuthException("Demasiados intentos. Espera unos minutos.", StatusCodes.Status429TooManyRequests); + } var usuario = await _dbContext.Usuarios .FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail && u.Activo, cancellationToken); if (usuario is null) { + var throttled = RecordLoginFailure(normalizedEmail, ipAddress); await _auditService.LogAsync( null, AuditActions.LoginFailed, "USUARIOS", null, ipAddress, - JsonSerializer.Serialize(new { email = normalizedEmail, motivo = "usuario_no_encontrado" }), + JsonSerializer.Serialize(new { email = normalizedEmail, motivo = throttled ? "rate_limited" : "usuario_no_encontrado" }), cancellationToken); + if (throttled) + { + throw new AuthException("Demasiados intentos. Espera unos minutos.", StatusCodes.Status429TooManyRequests); + } + throw new AuthException("Credenciales inválidas", StatusCodes.Status401Unauthorized); } if (usuario.LockedUntil.HasValue && usuario.LockedUntil.Value > now) { + var throttled = RecordLoginFailure(normalizedEmail, ipAddress); await _auditService.LogAsync( usuario.Id, AuditActions.AccountLocked, @@ -74,11 +101,30 @@ await _auditService.LogAsync( ipAddress, JsonSerializer.Serialize(new { email = normalizedEmail, locked_until = usuario.LockedUntil }), cancellationToken); - throw new AuthException("Usuario bloqueado temporalmente por intentos fallidos", StatusCodes.Status423Locked); + if (throttled) + { + throw new AuthException("Demasiados intentos. Espera unos minutos.", StatusCodes.Status429TooManyRequests); + } + + throw new AuthException("Credenciales inválidas", StatusCodes.Status401Unauthorized); } if (!BCrypt.Net.BCrypt.Verify(password, usuario.PasswordHash)) { + var throttled = RecordLoginFailure(normalizedEmail, ipAddress); + if (throttled) + { + await _auditService.LogAsync( + usuario.Id, + AuditActions.LoginFailed, + "USUARIOS", + usuario.Id, + ipAddress, + JsonSerializer.Serialize(new { email = normalizedEmail, motivo = "rate_limited" }), + cancellationToken); + throw new AuthException("Demasiados intentos. Espera unos minutos.", StatusCodes.Status429TooManyRequests); + } + usuario.FailedLoginAttempts += 1; var lockTriggered = false; if (usuario.FailedLoginAttempts >= MaxFailedLoginAttempts) @@ -119,7 +165,7 @@ await _auditService.LogAsync( if (lockTriggered) { - throw new AuthException("Usuario bloqueado temporalmente por intentos fallidos", StatusCodes.Status423Locked); + throw new AuthException("Credenciales inválidas", StatusCodes.Status401Unauthorized); } throw new AuthException("Credenciales inválidas", StatusCodes.Status401Unauthorized); @@ -128,6 +174,8 @@ await _auditService.LogAsync( usuario.FailedLoginAttempts = 0; usuario.LockedUntil = null; usuario.FechaUltimaLogin = now; + UserSessionState.EnsureSecurityStamp(usuario); + ClearLoginFailures(normalizedEmail, ipAddress); var tokens = await IssueTokensAsync(usuario, ipAddress, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); @@ -166,11 +214,25 @@ public async Task RefreshTokenAsync(string refreshToken, string? ipA .Include(rt => rt.Usuario) .FirstOrDefaultAsync(rt => rt.TokenHash == refreshHash, cancellationToken); - if (storedToken is null || storedToken.RevocadoEn.HasValue || storedToken.ExpiraEn <= now) + if (storedToken is null || storedToken.ExpiraEn <= now) { throw new AuthException("Refresh token inválido o expirado", StatusCodes.Status401Unauthorized); } + if (storedToken.RevocadoEn.HasValue) + { + if (!string.IsNullOrWhiteSpace(storedToken.ReemplazadoPor)) + { + await RevokeSessionsAfterRefreshReuseAsync(storedToken, now, ipAddress, cancellationToken); + if (tx is not null) + { + await tx.CommitAsync(cancellationToken); + } + } + + throw new AuthException("Refresh token inválido o expirado", StatusCodes.Status401Unauthorized); + } + var usuario = storedToken.Usuario; if (usuario is null || !usuario.Activo || usuario.DeletedAt.HasValue) { @@ -182,6 +244,8 @@ public async Task RefreshTokenAsync(string refreshToken, string? ipA throw new AuthException("Usuario bloqueado temporalmente por intentos fallidos", StatusCodes.Status423Locked); } + UserSessionState.EnsureSecurityStamp(usuario); + var replacement = GenerateRefreshToken(); var replacementHash = ComputeSha256(replacement); @@ -256,9 +320,9 @@ public async Task ChangePasswordAsync(Guid userId, string passwordAc throw new AuthException("Contraseña actual requerida", StatusCodes.Status400BadRequest); } - if (string.IsNullOrWhiteSpace(passwordNueva) || passwordNueva.Length < 8) + if (!SecurityPolicy.TryValidatePassword(passwordNueva, out var passwordError)) { - throw new AuthException("La nueva contraseña debe tener al menos 8 caracteres", StatusCodes.Status400BadRequest); + throw new AuthException(passwordError, StatusCodes.Status400BadRequest); } var usuario = await _dbContext.Usuarios.FirstOrDefaultAsync(u => u.Id == userId && u.Activo, cancellationToken); @@ -272,10 +336,11 @@ public async Task ChangePasswordAsync(Guid userId, string passwordAc throw new AuthException("Contraseña actual incorrecta", StatusCodes.Status400BadRequest); } + var now = DateTime.UtcNow; usuario.PasswordHash = BCrypt.Net.BCrypt.HashPassword(passwordNueva, workFactor: 12); usuario.PrimerLogin = false; + UserSessionState.RotateAfterPasswordChange(usuario, now); - var now = DateTime.UtcNow; var activeRefreshTokens = await _dbContext.RefreshTokens .Where(rt => rt.UsuarioId == userId && rt.RevocadoEn == null && rt.ExpiraEn > now) .ToListAsync(cancellationToken); @@ -299,11 +364,11 @@ public async Task ChangePasswordAsync(Guid userId, string passwordAc await _dbContext.SaveChangesAsync(cancellationToken); await _auditService.LogAsync( userId, - AuditActions.UpdateUsuario, + AuditActions.PasswordChanged, "USUARIOS", userId, ipAddress: ipAddress, - detallesJson: JsonSerializer.Serialize(new { cambio_password = true, usuario.PrimerLogin }), + detallesJson: JsonSerializer.Serialize(new { cambio_password = true, usuario.PrimerLogin, refresh_tokens_revocados = activeRefreshTokens.Count }), cancellationToken: cancellationToken); return await BuildAuthResultAsync(usuario, accessToken, newRefreshToken, cancellationToken); @@ -376,6 +441,7 @@ private async Task BuildAuthResultAsync(Usuario usuario, string? acc private string GenerateAccessToken(Usuario usuario) { + UserSessionState.EnsureSecurityStamp(usuario); var jwtSecret = _configuration["JwtSettings:Secret"] ?? throw new InvalidOperationException("JwtSettings:Secret is required"); var issuer = _configuration["JwtSettings:Issuer"] ?? "atlas-balance-api"; @@ -384,15 +450,23 @@ private string GenerateAccessToken(Usuario usuario) var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var expires = DateTime.UtcNow.AddMinutes(GetAccessTokenExpMinutes()); - var claims = new[] + var claims = new List { new Claim(JwtRegisteredClaimNames.Sub, usuario.Id.ToString()), new Claim(ClaimTypes.NameIdentifier, usuario.Id.ToString()), new Claim(ClaimTypes.Email, usuario.Email), new Claim(ClaimTypes.Name, usuario.NombreCompleto), - new Claim(ClaimTypes.Role, usuario.Rol.ToString()) + new Claim(ClaimTypes.Role, usuario.Rol.ToString()), + new Claim(AuthClaimNames.SecurityStamp, usuario.SecurityStamp) }; + if (usuario.PasswordChangedAt.HasValue) + { + claims.Add(new Claim( + AuthClaimNames.PasswordChangedAt, + new DateTimeOffset(usuario.PasswordChangedAt.Value).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture))); + } + var token = new JwtSecurityToken( issuer: issuer, audience: audience, @@ -403,6 +477,79 @@ private string GenerateAccessToken(Usuario usuario) return new JwtSecurityTokenHandler().WriteToken(token); } + private bool IsLoginThrottled(string normalizedEmail, string? ipAddress) + { + var key = BuildLoginFailureCacheKey(normalizedEmail, ipAddress); + lock (LoginRateLimitLock) + { + return _cache.TryGetValue(key, out var count) && + count >= MaxLoginFailuresPerClientAndEmail; + } + } + + private bool RecordLoginFailure(string normalizedEmail, string? ipAddress) + { + var key = BuildLoginFailureCacheKey(normalizedEmail, ipAddress); + lock (LoginRateLimitLock) + { + var count = _cache.Get(key) + 1; + _cache.Set(key, count, LoginFailureWindow); + return count >= MaxLoginFailuresPerClientAndEmail; + } + } + + private void ClearLoginFailures(string normalizedEmail, string? ipAddress) + { + _cache.Remove(BuildLoginFailureCacheKey(normalizedEmail, ipAddress)); + } + + private static string BuildLoginFailureCacheKey(string normalizedEmail, string? ipAddress) + { + var client = string.IsNullOrWhiteSpace(ipAddress) ? "unknown" : ipAddress.Trim(); + return $"auth:login-failures:{ComputeSha256($"{client}|{normalizedEmail}")}"; + } + + private async Task RevokeSessionsAfterRefreshReuseAsync( + RefreshToken reusedToken, + DateTime now, + string? ipAddress, + CancellationToken cancellationToken) + { + var usuario = reusedToken.Usuario ?? await _dbContext.Usuarios + .IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.Id == reusedToken.UsuarioId, cancellationToken); + if (usuario is null) + { + return; + } + + UserSessionState.RotateSecurityStamp(usuario); + var activeRefreshTokens = await _dbContext.RefreshTokens + .IgnoreQueryFilters() + .Where(rt => rt.UsuarioId == usuario.Id && rt.RevocadoEn == null && rt.ExpiraEn > now) + .ToListAsync(cancellationToken); + + foreach (var activeRefreshToken in activeRefreshTokens) + { + activeRefreshToken.RevocadoEn = now; + } + + await _auditService.LogAsync( + usuario.Id, + AuditActions.RefreshTokenReuseDetected, + "USUARIOS", + usuario.Id, + ipAddress, + JsonSerializer.Serialize(new + { + refresh_token_id = reusedToken.Id, + refresh_tokens_revocados = activeRefreshTokens.Count + }), + cancellationToken); + + await _dbContext.SaveChangesAsync(cancellationToken); + } + private static string GenerateRefreshToken() { var bytes = RandomNumberGenerator.GetBytes(64); diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/BackupService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/BackupService.cs index 91d193d..04f9136 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/BackupService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/BackupService.cs @@ -299,6 +299,11 @@ private static string ResolveSafeDirectory(string rawPath, string configKey) throw new InvalidOperationException($"Configuración '{configKey}' contiene segmentos de traversal."); } + if (!Path.IsPathRooted(trimmed) && !LooksLikeWindowsRootedPath(trimmed)) + { + throw new InvalidOperationException($"Configuracion '{configKey}' debe ser una ruta absoluta."); + } + string fullPath; try { @@ -316,4 +321,12 @@ private static string ResolveSafeDirectory(string rawPath, string configKey) return fullPath; } + + private static bool LooksLikeWindowsRootedPath(string value) + { + return value.Length >= 3 && + char.IsLetter(value[0]) && + value[1] == ':' && + (value[2] == '\\' || value[2] == '/'); + } } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/ExportacionService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/ExportacionService.cs index c61966c..63a09a8 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/ExportacionService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/ExportacionService.cs @@ -244,6 +244,11 @@ private static string ResolveSafeDirectory(string rawPath, string configKey) throw new InvalidOperationException($"Configuración '{configKey}' contiene segmentos de traversal."); } + if (!Path.IsPathRooted(trimmed) && !LooksLikeWindowsRootedPath(trimmed)) + { + throw new InvalidOperationException($"Configuracion '{configKey}' debe ser una ruta absoluta."); + } + string fullPath; try { @@ -261,4 +266,12 @@ private static string ResolveSafeDirectory(string rawPath, string configKey) return fullPath; } + + private static bool LooksLikeWindowsRootedPath(string value) + { + return value.Length >= 3 && + char.IsLetter(value[0]) && + value[1] == ':' && + (value[2] == '\\' || value[2] == '/'); + } } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/UserSessionState.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/UserSessionState.cs new file mode 100644 index 0000000..b6da7c1 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/UserSessionState.cs @@ -0,0 +1,31 @@ +using System.Security.Cryptography; +using GestionCaja.API.Models; + +namespace GestionCaja.API.Services; + +public static class UserSessionState +{ + public static string CreateSecurityStamp() + { + return Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + } + + public static void EnsureSecurityStamp(Usuario usuario) + { + if (string.IsNullOrWhiteSpace(usuario.SecurityStamp)) + { + usuario.SecurityStamp = CreateSecurityStamp(); + } + } + + public static void RotateAfterPasswordChange(Usuario usuario, DateTime changedAt) + { + usuario.SecurityStamp = CreateSecurityStamp(); + usuario.PasswordChangedAt = changedAt; + } + + public static void RotateSecurityStamp(Usuario usuario) + { + usuario.SecurityStamp = CreateSecurityStamp(); + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs b/Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs index 1ce6e6a..be64ddb 100644 --- a/Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs +++ b/Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs @@ -39,8 +39,22 @@ public async Task StartRestoreAsync(string backupPath, CancellationToken c return false; } - var fullBackupPath = Path.GetFullPath(backupPath); - if (!File.Exists(fullBackupPath) || !IsAllowedBackupPath(fullBackupPath)) + if (!IsAllowedBackupPath(backupPath)) + { + return false; + } + + string fullBackupPath; + try + { + fullBackupPath = Path.GetFullPath(backupPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + + if (!File.Exists(fullBackupPath)) { return false; } @@ -335,6 +349,11 @@ private static bool IsPathWithinRoot(string path, string root) return false; } + if (!IsExplicitlyRooted(path) || !IsExplicitlyRooted(root)) + { + return false; + } + try { var fullPath = EnsureTrailingSeparator(Path.GetFullPath(path)); @@ -354,6 +373,11 @@ private static bool PathsEqual(string left, string right) return false; } + if (!IsExplicitlyRooted(left) || !IsExplicitlyRooted(right)) + { + return false; + } + try { var normalizedLeft = Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); @@ -394,15 +418,41 @@ private static bool PathsEqual(string left, string right) private bool IsAllowedBackupPath(string backupPath) { + if (!IsExplicitlyRooted(backupPath)) + { + return false; + } + if (!string.Equals(Path.GetExtension(backupPath), ".dump", StringComparison.OrdinalIgnoreCase)) { return false; } var backupRoot = _configuration["WatchdogSettings:BackupPath"] ?? @"C:\AtlasBalance\backups"; - var fullRoot = EnsureTrailingSeparator(Path.GetFullPath(backupRoot)); - var fullBackupPath = Path.GetFullPath(backupPath); - return fullBackupPath.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase); + if (!IsExplicitlyRooted(backupRoot)) + { + return false; + } + + try + { + var fullRoot = EnsureTrailingSeparator(Path.GetFullPath(backupRoot)); + var fullBackupPath = Path.GetFullPath(backupPath); + return fullBackupPath.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static bool IsExplicitlyRooted(string path) + { + return Path.IsPathRooted(path) || + (path.Length >= 3 && + char.IsLetter(path[0]) && + path[1] == ':' && + (path[2] == '\\' || path[2] == '/')); } private static async Task<(bool Success, string? ErrorMessage)> RunProcessAsync( diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ActualizacionServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ActualizacionServiceTests.cs index cdf64f5..c0bd3e2 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ActualizacionServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ActualizacionServiceTests.cs @@ -84,6 +84,34 @@ public async Task CheckVersionDisponible_Should_Fallback_To_Default_Repo_Url_Whe result.VersionDisponible.Should().Be("v99.0.0"); } + [Fact] + public async Task CheckVersionDisponible_Should_Not_Request_Unsafe_Configured_Url() + { + await using var db = BuildDbContext(); + db.Configuraciones.Add(new Configuracion + { + Clave = "app_update_check_url", + Valor = "http://localhost/internal" + }); + await db.SaveChangesAsync(); + + Uri? requestedUri = null; + var handler = new StubHttpMessageHandler(request => + { + requestedUri = request.RequestUri; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"tag_name":"v99.0.0","name":"Release 99"}""") + }; + }); + var service = BuildService(db, handler); + + var result = await service.CheckVersionDisponibleAsync(CancellationToken.None); + + requestedUri.Should().Be(new Uri("https://api.github.com/repos/AtlasLabs797/AtlasBalance/releases/latest")); + result.VersionDisponible.Should().Be("v99.0.0"); + } + [Fact] public async Task CheckVersionDisponible_Should_Send_GitHub_Token_When_Configured() { diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs index 442a306..97c3409 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs @@ -30,14 +30,14 @@ private static AppDbContext BuildDbContext() } [Fact] - public async Task Login_Should_Lock_User_After_Five_Failed_Attempts() + public async Task Login_Should_Throttle_Client_Before_Global_Account_Lock() { await using var db = BuildDbContext(); var user = new Usuario { Id = Guid.NewGuid(), Email = "lock@test.local", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!", workFactor: 12), + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!Ab", workFactor: 12), NombreCompleto = "Lock User", Rol = RolUsuario.EMPLEADO, Activo = true, @@ -57,13 +57,41 @@ public async Task Login_Should_Lock_User_After_Five_Failed_Attempts() } Func fifthAttempt = () => sut.LoginAsync(user.Email, "BadPass!", "127.0.0.1", CancellationToken.None); - var locked = await fifthAttempt.Should().ThrowAsync(); - locked.Which.StatusCode.Should().Be(StatusCodes.Status423Locked); + var throttled = await fifthAttempt.Should().ThrowAsync(); + throttled.Which.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests); + throttled.Which.Message.Should().Be("Demasiados intentos. Espera unos minutos."); var persisted = await db.Usuarios.FirstAsync(x => x.Id == user.Id); - persisted.FailedLoginAttempts.Should().Be(5); - persisted.LockedUntil.Should().NotBeNull(); - persisted.LockedUntil.Should().BeAfter(DateTime.UtcNow.AddMinutes(29)); + persisted.FailedLoginAttempts.Should().Be(4); + persisted.LockedUntil.Should().BeNull(); + } + + [Fact] + public async Task Login_Should_Not_Reveal_When_User_Is_Already_Locked() + { + await using var db = BuildDbContext(); + var user = new Usuario + { + Id = Guid.NewGuid(), + Email = "already-locked@test.local", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!Ab", workFactor: 12), + NombreCompleto = "Already Locked", + Rol = RolUsuario.EMPLEADO, + Activo = true, + PrimerLogin = false, + LockedUntil = DateTime.UtcNow.AddMinutes(20), + FechaCreacion = DateTime.UtcNow + }; + db.Usuarios.Add(user); + await db.SaveChangesAsync(); + + var sut = new AuthService(db, BuildConfig(), new AuditService(db)); + + Func action = () => sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None); + + var exception = await action.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + exception.Which.Message.Should().Be("Credenciales inválidas"); } [Fact] @@ -74,7 +102,7 @@ public async Task Login_Should_Return_Tokens_And_Reset_Lock_Counters_When_Passwo { Id = Guid.NewGuid(), Email = "ok@test.local", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!", workFactor: 12), + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!Ab", workFactor: 12), NombreCompleto = "Ok User", Rol = RolUsuario.ADMIN, Activo = true, @@ -88,7 +116,7 @@ public async Task Login_Should_Return_Tokens_And_Reset_Lock_Counters_When_Passwo var sut = new AuthService(db, BuildConfig(), new AuditService(db)); - var result = await sut.LoginAsync(user.Email, "Valid1234!", "127.0.0.1", CancellationToken.None); + var result = await sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None); result.AccessToken.Should().NotBeNullOrWhiteSpace(); result.RefreshToken.Should().NotBeNullOrWhiteSpace(); @@ -110,7 +138,7 @@ public async Task ChangePassword_Should_Update_Hash_And_Clear_PrimerLogin() { Id = Guid.NewGuid(), Email = "pwd@test.local", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("OldPass123!", workFactor: 12), + PasswordHash = BCrypt.Net.BCrypt.HashPassword("OldPass123!Ab", workFactor: 12), NombreCompleto = "Pwd User", Rol = RolUsuario.EMPLEADO, Activo = true, @@ -122,11 +150,14 @@ public async Task ChangePassword_Should_Update_Hash_And_Clear_PrimerLogin() await db.SaveChangesAsync(); var sut = new AuthService(db, BuildConfig(), new AuditService(db)); - var result = await sut.ChangePasswordAsync(user.Id, "OldPass123!", "NewPass123!", "127.0.0.1", CancellationToken.None); + var originalStamp = user.SecurityStamp; + var result = await sut.ChangePasswordAsync(user.Id, "OldPass123!Ab", "NewPass12345!", "127.0.0.1", CancellationToken.None); var persisted = await db.Usuarios.FirstAsync(x => x.Id == user.Id); - BCrypt.Net.BCrypt.Verify("NewPass123!", persisted.PasswordHash).Should().BeTrue(); + BCrypt.Net.BCrypt.Verify("NewPass12345!", persisted.PasswordHash).Should().BeTrue(); persisted.PrimerLogin.Should().BeFalse(); + persisted.SecurityStamp.Should().NotBe(originalStamp); + persisted.PasswordChangedAt.Should().NotBeNull(); result.AccessToken.Should().NotBeNullOrWhiteSpace(); result.RefreshToken.Should().NotBeNullOrWhiteSpace(); } @@ -139,7 +170,7 @@ public async Task RefreshToken_Should_Reject_Locked_User() { Id = Guid.NewGuid(), Email = "refresh-locked@test.local", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!", workFactor: 12), + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!Ab", workFactor: 12), NombreCompleto = "Refresh Locked", Rol = RolUsuario.ADMIN, Activo = true, @@ -150,7 +181,7 @@ public async Task RefreshToken_Should_Reject_Locked_User() await db.SaveChangesAsync(); var sut = new AuthService(db, BuildConfig(), new AuditService(db)); - var login = await sut.LoginAsync(user.Email, "Valid1234!", "127.0.0.1", CancellationToken.None); + var login = await sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None); user.LockedUntil = DateTime.UtcNow.AddMinutes(30); await db.SaveChangesAsync(); @@ -168,7 +199,7 @@ public async Task ChangePassword_Should_Revoke_Previous_Refresh_Tokens_And_Issue { Id = Guid.NewGuid(), Email = "rotate@test.local", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("OldPass123!", workFactor: 12), + PasswordHash = BCrypt.Net.BCrypt.HashPassword("OldPass123!Ab", workFactor: 12), NombreCompleto = "Rotate User", Rol = RolUsuario.ADMIN, Activo = true, @@ -179,10 +210,10 @@ public async Task ChangePassword_Should_Revoke_Previous_Refresh_Tokens_And_Issue await db.SaveChangesAsync(); var sut = new AuthService(db, BuildConfig(), new AuditService(db)); - var login = await sut.LoginAsync(user.Email, "OldPass123!", "127.0.0.1", CancellationToken.None); + var login = await sut.LoginAsync(user.Email, "OldPass123!Ab", "127.0.0.1", CancellationToken.None); var previousHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(login.RefreshToken!))).ToLowerInvariant(); - var changed = await sut.ChangePasswordAsync(user.Id, "OldPass123!", "NewPass123!", "127.0.0.1", CancellationToken.None); + var changed = await sut.ChangePasswordAsync(user.Id, "OldPass123!Ab", "NewPass12345!", "127.0.0.1", CancellationToken.None); var newHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(changed.RefreshToken!))).ToLowerInvariant(); var previousToken = await db.RefreshTokens.SingleAsync(x => x.TokenHash == previousHash); @@ -192,6 +223,42 @@ public async Task ChangePassword_Should_Revoke_Previous_Refresh_Tokens_And_Issue newToken.RevocadoEn.Should().BeNull(); } + [Fact] + public async Task RefreshToken_Should_Revoke_Active_Sessions_When_Rotated_Token_Is_Reused() + { + await using var db = BuildDbContext(); + var user = new Usuario + { + Id = Guid.NewGuid(), + Email = "reuse@test.local", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!Ab", workFactor: 12), + NombreCompleto = "Reuse User", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false, + FechaCreacion = DateTime.UtcNow + }; + db.Usuarios.Add(user); + await db.SaveChangesAsync(); + + var sut = new AuthService(db, BuildConfig(), new AuditService(db)); + var login = await sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None); + var stampAfterLogin = (await db.Usuarios.SingleAsync(x => x.Id == user.Id)).SecurityStamp; + + var refreshed = await sut.RefreshTokenAsync(login.RefreshToken!, "127.0.0.1", CancellationToken.None); + var replacementHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(refreshed.RefreshToken!))).ToLowerInvariant(); + + Func reuse = () => sut.RefreshTokenAsync(login.RefreshToken!, "127.0.0.1", CancellationToken.None); + var exception = await reuse.Should().ThrowAsync(); + + exception.Which.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + var replacementToken = await db.RefreshTokens.SingleAsync(x => x.TokenHash == replacementHash); + replacementToken.RevocadoEn.Should().NotBeNull(); + var persisted = await db.Usuarios.SingleAsync(x => x.Id == user.Id); + persisted.SecurityStamp.Should().NotBe(stampAfterLogin); + (await db.Auditorias.AnyAsync(x => x.TipoAccion == GestionCaja.API.Constants.AuditActions.RefreshTokenReuseDetected)).Should().BeTrue(); + } + [Fact] public async Task Logout_Should_Revoke_Refresh_Token_And_Return_UserId() { @@ -200,7 +267,7 @@ public async Task Logout_Should_Revoke_Refresh_Token_And_Return_UserId() { Id = Guid.NewGuid(), Email = "logout@test.local", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!", workFactor: 12), + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!Ab", workFactor: 12), NombreCompleto = "Logout User", Rol = RolUsuario.ADMIN, Activo = true, @@ -211,7 +278,7 @@ public async Task Logout_Should_Revoke_Refresh_Token_And_Return_UserId() await db.SaveChangesAsync(); var sut = new AuthService(db, BuildConfig(), new AuditService(db)); - var login = await sut.LoginAsync(user.Email, "Valid1234!", "127.0.0.1", CancellationToken.None); + var login = await sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None); var revokedUserId = await sut.LogoutAsync(login.RefreshToken, CancellationToken.None); diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ConfiguracionControllerTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ConfiguracionControllerTests.cs index 6bc6a6b..f336de3 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ConfiguracionControllerTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ConfiguracionControllerTests.cs @@ -74,7 +74,7 @@ public async Task Update_Should_Preserve_Blank_SmtpPassword_And_Redact_Audit() General = new UpdateGeneralConfigRequest { AppBaseUrl = "https://app.local", - AppUpdateCheckUrl = "https://updates.local/version.json", + AppUpdateCheckUrl = ConfigurationDefaults.UpdateCheckUrl, BackupPath = "C:\\backups", ExportPath = "C:\\exports" }, @@ -90,13 +90,44 @@ public async Task Update_Should_Preserve_Blank_SmtpPassword_And_Redact_Audit() var smtpPassword = await db.Configuraciones.SingleAsync(x => x.Clave == "smtp_password"); smtpPassword.Valor.Should().Be("super-secret"); var updateUrl = await db.Configuraciones.SingleAsync(x => x.Clave == "app_update_check_url"); - updateUrl.Valor.Should().Be("https://updates.local/version.json"); + updateUrl.Valor.Should().Be(ConfigurationDefaults.UpdateCheckUrl); var audit = await db.Auditorias.SingleAsync(x => x.TipoAccion == AuditActions.UpdateConfiguracion); audit.DetallesJson.Should().NotContain("super-secret"); audit.DetallesJson.Should().Contain("[REDACTED]"); } + [Fact] + public async Task Update_Should_Reject_NonOfficial_Update_Check_Url() + { + await using var db = BuildDbContext(); + var controller = BuildController(db); + + var result = await controller.Update(new UpdateConfiguracionRequest + { + Smtp = new UpdateSmtpConfigRequest + { + Host = "smtp.local", + Port = 587, + User = "user", + Password = "", + From = "noreply@test.local" + }, + General = new UpdateGeneralConfigRequest + { + AppBaseUrl = "https://app.local", + AppUpdateCheckUrl = "http://localhost/internal", + BackupPath = "C:\\backups", + ExportPath = "C:\\exports" + }, + Dashboard = new UpdateDashboardConfigRequest() + }, CancellationToken.None); + + result.Should().BeOfType(); + (await db.Configuraciones.AnyAsync(x => x.Clave == "app_update_check_url")).Should().BeFalse(); + (await db.Auditorias.AnyAsync()).Should().BeFalse(); + } + private static ConfiguracionController BuildController(AppDbContext db) { var userId = Guid.NewGuid(); diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExportacionServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExportacionServiceTests.cs index 88fd44f..463d186 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExportacionServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExportacionServiceTests.cs @@ -86,4 +86,41 @@ public async Task ExportarCuentaAsync_Should_Create_A_Different_File_For_Each_Ru } } } + + [Fact] + public async Task ExportarCuentaAsync_Should_Reject_Relative_Export_Path() + { + await using var db = BuildDbContext(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + db.Configuraciones.Add(new Configuracion + { + Clave = "export_path", + Valor = "exports", + Tipo = "string", + Descripcion = "Ruta relativa insegura" + }); + db.Titulares.Add(new Titular + { + Id = titularId, + Nombre = "Titular QA", + Tipo = TipoTitular.EMPRESA + }); + db.Cuentas.Add(new Cuenta + { + Id = cuentaId, + TitularId = titularId, + Nombre = "Cuenta QA", + Divisa = "EUR", + Activa = true + }); + await db.SaveChangesAsync(); + + var service = new ExportacionService(db, new AuditService(db)); + + var act = () => service.ExportarCuentaAsync(cuentaId, TipoProceso.MANUAL, null, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*ruta absoluta*"); + } } diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs index 580bb0c..808347b 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs @@ -193,6 +193,77 @@ public async Task Listar_Should_Not_Return_Deleted_Rows_To_NonAdmin_Even_When_Re page.Data.Single().DeletedAt.Should().BeNull(); } + [Fact] + public async Task Listar_Should_Return_Empty_For_DashboardOnly_GlobalPermission() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var titularAId = Guid.NewGuid(); + var titularBId = Guid.NewGuid(); + var cuentaAId = Guid.NewGuid(); + var cuentaBId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = userId, + Email = "gerente.dashboard-only@test.local", + PasswordHash = "hash", + NombreCompleto = "Gerente Dashboard", + Rol = RolUsuario.GERENTE, + Activo = true, + PrimerLogin = false + }); + db.Titulares.AddRange( + new Titular { Id = titularAId, Nombre = "Titular A", Tipo = TipoTitular.EMPRESA }, + new Titular { Id = titularBId, Nombre = "Titular B", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.AddRange( + new Cuenta { Id = cuentaAId, TitularId = titularAId, Nombre = "Cuenta A", Divisa = "EUR", Activa = true }, + new Cuenta { Id = cuentaBId, TitularId = titularBId, Nombre = "Cuenta B", Divisa = "USD", Activa = true }); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = null, + TitularId = null, + PuedeAgregarLineas = false, + PuedeEditarLineas = false, + PuedeEliminarLineas = false, + PuedeImportar = false, + PuedeVerDashboard = true + }); + db.Extractos.AddRange( + new Extracto + { + Id = Guid.NewGuid(), + CuentaId = cuentaAId, + Fecha = DateOnly.FromDateTime(DateTime.UtcNow.Date), + Concepto = "Cuenta A", + Monto = 10m, + Saldo = 10m, + FilaNumero = 1 + }, + new Extracto + { + Id = Guid.NewGuid(), + CuentaId = cuentaBId, + Fecha = DateOnly.FromDateTime(DateTime.UtcNow.Date), + Concepto = "Cuenta B", + Monto = 20m, + Saldo = 20m, + FilaNumero = 1 + }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, userId, RolUsuario.GERENTE); + + var result = await controller.Listar(ct: CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var page = ok.Value.Should().BeOfType>().Subject; + page.Total.Should().Be(0); + page.Data.Should().BeEmpty(); + } + [Fact] public async Task GetCuentasTitular_Should_Forbid_Unauthorized_Titular() { diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationAuthMiddlewareTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationAuthMiddlewareTests.cs index 863b899..942bfaa 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationAuthMiddlewareTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationAuthMiddlewareTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; using Xunit; namespace GestionCaja.API.Tests; @@ -104,6 +105,36 @@ public async Task IntegrationAudit_Should_Persist_Even_If_Client_Cancels() logs[0].CodigoRespuesta.Should().Be(StatusCodes.Status500InternalServerError); } + [Fact] + public async Task InvalidBearer_Should_RateLimit_Before_Revalidating_Token() + { + await using var db = BuildDbContext(); + var clock = new FakeClock(new DateTime(2026, 4, 18, 12, 30, 0, DateTimeKind.Utc)); + var cache = new MemoryCache(new MemoryCacheOptions()); + var tokenService = new CountingInvalidTokenService(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["IntegrationSecurity:InvalidAuthLimitPerMinute"] = "2" + }) + .Build(); + + var middleware = new IntegrationAuthMiddleware( + _ => Task.CompletedTask, + cache, + clock, + configuration); + + (await InvokeWithTokenAsync(middleware, db, tokenService, "bad-token-1", CancellationToken.None)) + .Should().Be(StatusCodes.Status401Unauthorized); + (await InvokeWithTokenAsync(middleware, db, tokenService, "bad-token-2", CancellationToken.None)) + .Should().Be(StatusCodes.Status401Unauthorized); + (await InvokeWithTokenAsync(middleware, db, tokenService, "bad-token-3", CancellationToken.None)) + .Should().Be(StatusCodes.Status429TooManyRequests); + + tokenService.ValidateCalls.Should().Be(2); + } + private static async Task InvokeWithTokenAsync( IntegrationAuthMiddleware middleware, AppDbContext db, @@ -132,4 +163,22 @@ public FakeClock(DateTime utcNow) public DateTime UtcNow { get; set; } } + + private sealed class CountingInvalidTokenService : IIntegrationTokenService + { + public int ValidateCalls { get; private set; } + + public string GeneratePlainToken() => "sk_test_invalid"; + + public string ComputeSha256(string value) => value; + + public Task ValidateActiveTokenAsync(string? plainToken, CancellationToken cancellationToken) + { + ValidateCalls++; + return Task.FromResult(null); + } + + public Task RevokeAsync(Guid tokenId, CancellationToken cancellationToken) + => Task.FromResult(false); + } } diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/UserStateMiddlewareTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UserStateMiddlewareTests.cs new file mode 100644 index 0000000..8c69cc7 --- /dev/null +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UserStateMiddlewareTests.cs @@ -0,0 +1,102 @@ +using System.Security.Claims; +using FluentAssertions; +using GestionCaja.API.Constants; +using GestionCaja.API.Data; +using GestionCaja.API.Middleware; +using GestionCaja.API.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace GestionCaja.API.Tests; + +public sealed class UserStateMiddlewareTests +{ + private static AppDbContext BuildDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new AppDbContext(options); + } + + [Fact] + public async Task InvokeAsync_Should_Reject_Token_When_SecurityStamp_Is_Stale() + { + await using var db = BuildDbContext(); + var user = new Usuario + { + Id = Guid.NewGuid(), + Email = "stale@test.local", + NombreCompleto = "Stale User", + PasswordHash = "hash", + Rol = RolUsuario.ADMIN, + Activo = true, + SecurityStamp = "current-stamp" + }; + db.Usuarios.Add(user); + await db.SaveChangesAsync(); + + var nextCalled = false; + var middleware = new UserStateMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + + var context = BuildContext(user.Id, "old-stamp"); + + await middleware.InvokeAsync(context, db); + + context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + nextCalled.Should().BeFalse(); + } + + [Fact] + public async Task InvokeAsync_Should_Continue_When_SecurityStamp_Matches() + { + await using var db = BuildDbContext(); + var user = new Usuario + { + Id = Guid.NewGuid(), + Email = "fresh@test.local", + NombreCompleto = "Fresh User", + PasswordHash = "hash", + Rol = RolUsuario.ADMIN, + Activo = true, + SecurityStamp = "fresh-stamp" + }; + db.Usuarios.Add(user); + await db.SaveChangesAsync(); + + var nextCalled = false; + var middleware = new UserStateMiddleware(context => + { + nextCalled = true; + context.Response.StatusCode = StatusCodes.Status204NoContent; + return Task.CompletedTask; + }); + + var context = BuildContext(user.Id, user.SecurityStamp); + + await middleware.InvokeAsync(context, db); + + context.Response.StatusCode.Should().Be(StatusCodes.Status204NoContent); + nextCalled.Should().BeTrue(); + } + + private static DefaultHttpContext BuildContext(Guid userId, string securityStamp) + { + var context = new DefaultHttpContext(); + context.Request.Path = "/api/usuarios"; + context.Response.Body = new MemoryStream(); + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim(ClaimTypes.Role, nameof(RolUsuario.ADMIN)), + new Claim(AuthClaimNames.SecurityStamp, securityStamp) + ], "TestAuth")); + + return context; + } +} diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs index 3a5c7d5..0a6aac2 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs @@ -45,7 +45,7 @@ public async Task Crear_Should_Create_User_With_Emails_And_Permissions_And_Audit } }; - var credentialValue = string.Concat("QaUser", "1234!"); + var credentialValue = string.Concat("QaUser", "123456!"); var request = new CreateUsuarioRequest { Email = "controller.test@atlasbalance.local", @@ -89,4 +89,76 @@ public async Task Crear_Should_Create_User_With_Emails_And_Permissions_And_Audit var auditRows = await db.Auditorias.Where(x => x.EntidadId == created.Id && x.TipoAccion == AuditActions.CreateUsuario).ToListAsync(); auditRows.Should().HaveCount(1); } + + [Fact] + public async Task Actualizar_Should_Revoke_Sessions_When_Admin_Resets_Password() + { + await using var db = BuildDbContext(); + var audit = new AuditService(db); + var controller = new UsuariosController(db, audit); + var adminId = Guid.NewGuid(); + controller.ControllerContext = BuildControllerContext(adminId); + + var user = new Usuario + { + Id = Guid.NewGuid(), + Email = "reset.target@atlasbalance.local", + NombreCompleto = "Reset Target", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("OldPass123!", workFactor: 12), + Rol = RolUsuario.EMPLEADO, + Activo = true, + PrimerLogin = false, + FechaCreacion = DateTime.UtcNow + }; + db.Usuarios.Add(user); + db.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + UsuarioId = user.Id, + TokenHash = "token-hash", + ExpiraEn = DateTime.UtcNow.AddDays(1), + CreadoEn = DateTime.UtcNow + }); + await db.SaveChangesAsync(); + var originalStamp = user.SecurityStamp; + + var request = new UpdateUsuarioRequest + { + Email = user.Email, + NombreCompleto = user.NombreCompleto, + Rol = user.Rol, + Activo = true, + PrimerLogin = false, + PasswordNueva = "ResetPass12345!", + Emails = new[] { user.Email }, + Permisos = Array.Empty() + }; + + var result = await controller.Actualizar(user.Id, request, CancellationToken.None); + + result.Should().BeOfType(); + var persisted = await db.Usuarios.SingleAsync(x => x.Id == user.Id); + persisted.SecurityStamp.Should().NotBe(originalStamp); + persisted.PasswordChangedAt.Should().NotBeNull(); + BCrypt.Net.BCrypt.Verify("ResetPass12345!", persisted.PasswordHash).Should().BeTrue(); + (await db.RefreshTokens.SingleAsync(x => x.UsuarioId == user.Id)).RevocadoEn.Should().NotBeNull(); + (await db.Auditorias.AnyAsync(x => x.EntidadId == user.Id && x.TipoAccion == AuditActions.PasswordReset)).Should().BeTrue(); + } + + private static ControllerContext BuildControllerContext(Guid adminId) + { + var identity = new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, adminId.ToString()), + new Claim(ClaimTypes.Role, "ADMIN") + }, "TestAuth"); + + return new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(identity) + } + }; + } } diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs index 0bd439f..0b30450 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs @@ -89,17 +89,44 @@ public async Task StartUpdateAsync_Should_Reject_Source_Outside_Configured_Updat Directory.Delete(root, recursive: true); } + [Fact] + public async Task StartRestoreAsync_Should_Reject_Relative_Backup_Path() + { + var root = CreateTempDirectory(); + var backupFile = Path.Combine(root, "backup.dump"); + await File.WriteAllTextAsync(backupFile, "not-a-real-dump"); + + var originalDirectory = Environment.CurrentDirectory; + try + { + Directory.SetCurrentDirectory(root); + var stateStore = new FakeWatchdogStateStore(); + var service = CreateService(stateStore, backupPathRoot: root); + + var accepted = await service.StartRestoreAsync("backup.dump", CancellationToken.None); + + accepted.Should().BeFalse(); + } + finally + { + Directory.SetCurrentDirectory(originalDirectory); + Directory.Delete(root, recursive: true); + } + } + private static WatchdogOperationsService CreateService( FakeWatchdogStateStore stateStore, string? updateSourceRoot = null, - string? updateTargetPath = null) + string? updateTargetPath = null, + string? backupPathRoot = null) { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["WatchdogSettings:ApiServiceName"] = $"FakeService-{Guid.NewGuid():N}", ["WatchdogSettings:UpdateSourceRoot"] = updateSourceRoot, - ["WatchdogSettings:UpdateTargetPath"] = updateTargetPath + ["WatchdogSettings:UpdateTargetPath"] = updateTargetPath, + ["WatchdogSettings:BackupPath"] = backupPathRoot }) .Build(); diff --git a/Atlas Balance/frontend/package-lock.json b/Atlas Balance/frontend/package-lock.json index 6650805..7ef249c 100644 --- a/Atlas Balance/frontend/package-lock.json +++ b/Atlas Balance/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "atlas-balance-frontend", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "atlas-balance-frontend", - "version": "1.2.0", + "version": "1.3.0", "dependencies": { "@tanstack/react-virtual": "^3.11.2", "axios": "^1.7.9", @@ -2874,9 +2874,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { diff --git a/Atlas Balance/frontend/package.json b/Atlas Balance/frontend/package.json index 1328757..5102069 100644 --- a/Atlas Balance/frontend/package.json +++ b/Atlas Balance/frontend/package.json @@ -1,8 +1,8 @@ { "name": "atlas-balance-frontend", "private": true, - "version": "1.2.0", - "appVersion": "V-01.02", + "version": "1.3.0", + "appVersion": "V-01.03", "type": "module", "scripts": { "dev": "vite", diff --git a/Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx b/Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx index db40d36..2377b5d 100644 --- a/Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx +++ b/Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx @@ -300,8 +300,13 @@ export default function UsuarioModal({ return; } - if (!editingId && form.password.length < 8) { - setError('Password mínimo 8 caracteres para crear usuario'); + if (!editingId && form.password.length < 12) { + setError('Password mínimo 12 caracteres para crear usuario'); + return; + } + + if (editingId && form.password.length > 0 && form.password.length < 12) { + setError('Password mínimo 12 caracteres para cambiarlo'); return; } @@ -427,7 +432,7 @@ export default function UsuarioModal({ {editingId ? 'Nueva contraseña (opcional)' : 'Contraseña inicial'} setForm((prev) => ({ ...prev, password: event.target.value })) diff --git a/Atlas Balance/frontend/src/pages/ChangePasswordPage.tsx b/Atlas Balance/frontend/src/pages/ChangePasswordPage.tsx index a6d3e31..e2e1957 100644 --- a/Atlas Balance/frontend/src/pages/ChangePasswordPage.tsx +++ b/Atlas Balance/frontend/src/pages/ChangePasswordPage.tsx @@ -56,7 +56,7 @@ export default function ChangePasswordPage() {
- + {errors.passwordNueva &&

{errors.passwordNueva.message}

}
diff --git a/Atlas Balance/frontend/src/pages/CuentaDetailPage.tsx b/Atlas Balance/frontend/src/pages/CuentaDetailPage.tsx index fcf7b97..3e6fcd9 100644 --- a/Atlas Balance/frontend/src/pages/CuentaDetailPage.tsx +++ b/Atlas Balance/frontend/src/pages/CuentaDetailPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom'; +import { AxiosError } from 'axios'; import ConfirmDialog from '@/components/common/ConfirmDialog'; import { EmptyState } from '@/components/common/EmptyState'; import { PageSkeleton } from '@/components/common/PageSkeleton'; @@ -63,6 +64,7 @@ export default function CuentaDetailPage() { const [notesStatus, setNotesStatus] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [forbidden, setForbidden] = useState(false); const allowedDashboard = usuario?.rol === 'ADMIN' || (usuario?.rol === 'GERENTE' && canViewDashboard()); const canImport = Boolean(cuentaId) && summary ? canImportInCuenta(cuentaId, summary.titular_id) : false; @@ -93,6 +95,7 @@ export default function CuentaDetailPage() { setLoading(true); setError(null); + setForbidden(false); try { const [summaryRes, rowsRes] = await Promise.all([ @@ -105,6 +108,13 @@ export default function CuentaDetailPage() { setSummary(summaryRes.data); setRows(rowsRes.data.data ?? []); } catch (err) { + if (err instanceof AxiosError && err.response?.status === 403) { + setForbidden(true); + setSummary(null); + setRows([]); + return; + } + setError(extractErrorMessage(err, 'No se pudo cargar la cuenta')); } finally { setLoading(false); @@ -295,6 +305,10 @@ export default function CuentaDetailPage() { return ; } + if (forbidden) { + return ; + } + if (loading) return ; if (error) return

{error}

; if (!summary) return ; diff --git a/Atlas Balance/frontend/src/pages/CuentasPage.tsx b/Atlas Balance/frontend/src/pages/CuentasPage.tsx index 6cf5be7..828842d 100644 --- a/Atlas Balance/frontend/src/pages/CuentasPage.tsx +++ b/Atlas Balance/frontend/src/pages/CuentasPage.tsx @@ -109,6 +109,7 @@ export default function CuentasPage() { const navigate = useNavigate(); const usuario = useAuthStore((state) => state.usuario); const canViewDashboard = usePermisosStore((state) => state.canViewDashboard); + const canViewCuenta = usePermisosStore((state) => state.canViewCuenta); const isAdmin = usuario?.rol === 'ADMIN'; const canSeeDashboard = usuario?.rol === 'ADMIN' || (usuario?.rol === 'GERENTE' && canViewDashboard()); @@ -510,30 +511,52 @@ export default function CuentasPage() { Detalle - {saldosCuentaRows.map((item) => ( - - -