Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ public async Task<IActionResult> Logout(CancellationToken cancellationToken)
DeleteCookie("access_token");
DeleteCookie("refresh_token");
DeleteCookie("csrf_token");
DeleteCookie("mfa_trusted");

var actorUserId = TryGetUserId(out var authenticatedUserId)
? authenticatedUserId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ await _dbContext.Usuarios
AppUpdateAutoLastStartedUtc = GetValue(config, "app_update_auto_last_started_utc"),
AppUpdateAutoLastResult = GetValue(config, "app_update_auto_last_result"),
BackupPath = GetValue(config, "backup_path"),
ExportPath = GetValue(config, "export_path")
ExportPath = GetValue(config, "export_path"),
MfaRememberDeviceEnabled = ParseBool(GetValue(config, "mfa_remember_device_enabled"), fallback: false),
MfaRememberDays = Math.Clamp(ParseInt(GetValue(config, "mfa_remember_days"), 62), 1, 180)
},
Exchange = new ExchangeRateConfigResponse
{
Expand Down Expand Up @@ -190,6 +192,8 @@ public async Task<IActionResult> Update([FromBody] UpdateConfiguracionRequest re
Upsert(config, "app_update_auto_hour_utc", request.General.AppUpdateAutoHourUtc.ToString(CultureInfo.InvariantCulture), userId, now);
Upsert(config, "backup_path", request.General.BackupPath.Trim(), userId, now);
Upsert(config, "export_path", request.General.ExportPath.Trim(), userId, now);
Upsert(config, "mfa_remember_device_enabled", request.General.MfaRememberDeviceEnabled ? "true" : "false", userId, now);
Upsert(config, "mfa_remember_days", Math.Clamp(request.General.MfaRememberDays, 1, 180).ToString(CultureInfo.InvariantCulture), userId, now);
var exchangeApiKey = request.Exchange?.ApiKey;
if (!string.IsNullOrWhiteSpace(exchangeApiKey))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ public sealed class GeneralConfigResponse
public string AppUpdateAutoLastResult { get; set; } = string.Empty;
public string BackupPath { get; set; } = string.Empty;
public string ExportPath { get; set; } = string.Empty;
public bool MfaRememberDeviceEnabled { get; set; }
public int MfaRememberDays { get; set; } = 62;
}

public sealed class DashboardConfigResponse
Expand Down Expand Up @@ -72,6 +74,8 @@ public sealed class UpdateGeneralConfigRequest
public int AppUpdateAutoHourUtc { get; set; } = 3;
public string BackupPath { get; set; } = string.Empty;
public string ExportPath { get; set; } = string.Empty;
public bool MfaRememberDeviceEnabled { get; set; }
public int MfaRememberDays { get; set; } = 62;
}

public sealed class UpdateExchangeRateConfigRequest
Expand Down
2 changes: 2 additions & 0 deletions Atlas Balance/backend/src/AtlasBalance.API/Data/SeedData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ private static void EnsureDefaultConfiguraciones(AppDbContext context, Guid? see
["backup_retention_weeks"] = ("6", "int", "Semanas de retencion de backups"),
["backup_path"] = ("C:/AtlasBalance/backups", "string", "Ruta de almacenamiento de backups"),
["export_path"] = ("C:/AtlasBalance/exports", "string", "Ruta de exportaciones"),
["mfa_remember_device_enabled"] = ("false", "bool", "Permite recordar dispositivos para omitir MFA en logins futuros"),
["mfa_remember_days"] = ("62", "int", "Dias de confianza de dispositivo recordado para MFA"),
["app_version"] = ("V-01.07", "string", "Version instalada"),
["app_update_check_url"] = (ConfigurationDefaults.UpdateCheckUrl, "string", "Repositorio oficial de GitHub para actualizaciones"),
["app_update_auto_enabled"] = ("false", "bool", "Aplicar automaticamente releases firmados de GitHub"),
Expand Down
26 changes: 23 additions & 3 deletions Atlas Balance/backend/src/AtlasBalance.API/Services/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public sealed class AuthService : IAuthService
private static readonly TimeSpan LoginFailureWindow = TimeSpan.FromMinutes(15);
private static readonly TimeSpan MfaChallengeDuration = TimeSpan.FromMinutes(5);
private static readonly TimeSpan MfaFailureWindow = TimeSpan.FromMinutes(15);
private static readonly TimeSpan MfaRememberDuration = TimeSpan.FromDays(90);
private const int DefaultMfaRememberDays = 62;
private static readonly IMemoryCache FallbackMemoryCache = new MemoryCache(new MemoryCacheOptions());

private readonly AppDbContext _dbContext;
Expand Down Expand Up @@ -360,9 +360,10 @@ await _auditService.LogAsync(

RemoveMfaChallenge(challengeId);
var result = await BuildAuthResultAsync(usuario, tokens.AccessToken, tokens.RefreshToken, cancellationToken);
if (rememberDevice)
var mfaRememberSettings = await GetMfaRememberSettingsAsync(cancellationToken);
if (mfaRememberSettings.Enabled)
{
result.TrustedMfaTokenExpiresAt = now.Add(MfaRememberDuration);
result.TrustedMfaTokenExpiresAt = now.AddDays(mfaRememberSettings.Days);
result.TrustedMfaToken = GenerateTrustedMfaToken(usuario, result.TrustedMfaTokenExpiresAt.Value);
}

Expand Down Expand Up @@ -552,6 +553,25 @@ await _auditService.LogAsync(
return await BuildAuthResultAsync(usuario, accessToken, newRefreshToken, cancellationToken);
}


private async Task<(bool Enabled, int Days)> GetMfaRememberSettingsAsync(CancellationToken cancellationToken)
{
var config = await _dbContext.Configuraciones
.Where(c => c.Clave == "mfa_remember_device_enabled" || c.Clave == "mfa_remember_days")
.ToDictionaryAsync(c => c.Clave, c => c.Valor, StringComparer.OrdinalIgnoreCase, cancellationToken);

var enabled = config.TryGetValue("mfa_remember_device_enabled", out var rawEnabled)
? bool.TryParse(rawEnabled, out var parsed) && parsed
: false;

var days = DefaultMfaRememberDays;
if (config.TryGetValue("mfa_remember_days", out var rawDays) && int.TryParse(rawDays, out var parsedDays))
{
days = Math.Clamp(parsedDays, 1, 180);
}

return (enabled, days);
}
private async Task<AuthResult> BuildAuthResultAsync(Usuario usuario, string? accessToken, string? refreshToken, CancellationToken cancellationToken)
{
var permisos = await _dbContext.PermisosUsuario
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace AtlasBalance.API.Tests;
public sealed class AuthControllerTests
{
[Fact]
public async Task Logout_Should_Not_Delete_Trusted_Mfa_Cookie()
public async Task Logout_Should_Delete_Trusted_Mfa_Cookie()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
Expand All @@ -37,7 +37,7 @@ public async Task Logout_Should_Not_Delete_Trusted_Mfa_Cookie()
setCookie.Should().Contain("access_token=");
setCookie.Should().Contain("refresh_token=");
setCookie.Should().Contain("csrf_token=");
setCookie.Should().NotContain("mfa_trusted");
setCookie.Should().Contain("mfa_trusted=");
}

private sealed class LogoutOnlyAuthService : IAuthService
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ public async Task Login_Should_Not_Require_Mfa_Again_When_Trusted_Mfa_Cookie_Is_
var trustedLogin = await sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None, verified.TrustedMfaToken);

verified.TrustedMfaToken.Should().NotBeNullOrWhiteSpace();
verified.TrustedMfaTokenExpiresAt.Should().BeAfter(DateTime.UtcNow.AddDays(89));
verified.TrustedMfaTokenExpiresAt.Should().BeAfter(DateTime.UtcNow.AddDays(61));
trustedLogin.MfaRequired.Should().BeFalse();
trustedLogin.AccessToken.Should().NotBeNullOrWhiteSpace();
trustedLogin.RefreshToken.Should().NotBeNullOrWhiteSpace();
Expand Down
37 changes: 37 additions & 0 deletions Documentacion/DOCUMENTACION_CAMBIOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14536,3 +14536,40 @@ La primera ronda corrigió timing y animaciones no funcionales, pero el icono se
- Endurecer `backup_path`/`export_path` con allowlist de raices si se acepta una migracion de configuracion.
- Limitar tamano y contenido de paquetes de actualizacion antes de extraerlos.
- Revisar en otra pasada fingerprint de importacion, disposal de transacciones de importacion, calculo de saldo actual por fecha/fila, `ConfiguracionController` con JSON nulo y cooldown de alertas SMTP fallidas.

## 2026-05-20 - V-01.07 - Hardening MFA: logout invalida confianza y recuerdo administrado

**Trabajo realizado:**
- Se corrige la regresion de seguridad: `logout` vuelve a borrar la cookie `mfa_trusted` junto con access/refresh/csrf.
- Se elimina el hardcode de 90 dias y se pasa a configuracion administrable:
- `mfa_remember_device_enabled` (bool): solo el admin decide si se permite recordar dispositivos.
- `mfa_remember_days` (int): dias de confianza (default 62, rango 1..180).
- En `VerifyMfaAsync`, el backend ignora la decision del usuario y aplica el recordatorio solo si la configuracion admin esta habilitada.
- Se exponen ambos campos en `GET/PUT /api/configuracion` y se seedean valores por defecto seguros (`false`, `62`).
- Se actualizan tests para el nuevo comportamiento de logout y la ventana de 62 dias.

**Archivos tocados:**
- `Atlas Balance/backend/src/AtlasBalance.API/Controllers/AuthController.cs`
- `Atlas Balance/backend/src/AtlasBalance.API/Services/AuthService.cs`
- `Atlas Balance/backend/src/AtlasBalance.API/Controllers/ConfiguracionController.cs`
- `Atlas Balance/backend/src/AtlasBalance.API/DTOs/ConfiguracionDtos.cs`
- `Atlas Balance/backend/src/AtlasBalance.API/Data/SeedData.cs`
- `Atlas Balance/backend/tests/AtlasBalance.API.Tests/AuthControllerTests.cs`
- `Atlas Balance/backend/tests/AtlasBalance.API.Tests/AuthServiceTests.cs`

**Comandos ejecutados:**
- `rg --files -g 'AGENTS.md'`
- `cat AGENTS.md`
- `cat Atlas Balance/AGENTS.md`
- `cat CLAUDE.md`
- `cat Documentacion/Versiones/version_actual.md`
- `cat Documentacion/Versiones/v-01.07.md`
- `cat Documentacion/LOG_ERRORES_INCIDENCIAS.md`
- `rg -n "mfa_trusted|MfaRememberDuration|rememberDevice|Logout" ...`
- `dotnet test backend/tests/AtlasBalance.API.Tests/AtlasBalance.API.Tests.csproj --filter "FullyQualifiedName~AuthControllerTests|FullyQualifiedName~AuthServiceTests"`

**Resultado de verificacion:**
- Pendiente en este entorno si `dotnet` no esta disponible; validacion estatica completada y tests listos para ejecutar en CI/entorno con SDK.

**Pendientes:**
- Conectar esta nueva configuracion en la UI de administracion para que el admin la gestione visualmente si aun no existe el formulario.
Loading