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