From 6f9392f3d01282bc6d8ffb503cc0213e93033e69 Mon Sep 17 00:00:00 2001 From: Pedro Date: Mon, 27 Jul 2026 08:46:41 +0200 Subject: [PATCH] cambiso en denuncias, mejoras de 30 min en inicio de sesion e incorporacion de grupos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cambiso de certificado en registro de personal para aaƱadir el origen --- .../Controllers/AuthController.cs | 10 +- .../Controllers/InboxController.cs | 51 ++++- Antifraude.Net/ApiDenuncias/Program.cs | 1 + .../Services/GlobalLeaksClient.cs | 106 ++++++++++- .../GlobalLeaksSessionKeepAliveService.cs | 83 ++++++++ .../Services/GlobalLeaksSessionStore.cs | 178 ++++++++++++++---- .../Services/InboxTrackingService.cs | 11 +- .../Services/UserComplaintAccessService.cs | 32 ++-- .../Models/GlSession.cs | 10 +- .../Models/GlobalLeaksStoredSession.cs | 7 +- .../GestionaDenunciasAN/Components/App.razor | 2 +- .../Components/Layout/MainLayout.razor | 125 +++++++++++- .../Services/ApiDenunciasClient.cs | 9 + .../GestionaDenunciasAN/wwwroot/js/appAuth.js | 59 ++++++ .../Services/CertificateLoginBridgeService.cs | 4 +- 15 files changed, 624 insertions(+), 64 deletions(-) create mode 100644 Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionKeepAliveService.cs diff --git a/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs b/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs index 083f3c6..e29744e 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/AuthController.cs @@ -304,7 +304,15 @@ public sealed class AuthController : ControllerBase : session.Username.Trim(); _logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username); - await _sessionStore.SaveAsync(username, password, session.Id, session.Role, session.DpopPrivateKey, cancellationToken); + await _sessionStore.SaveAsync( + username, + password, + session.Id, + session.Role, + session.DpopPrivateKey, + session.ProofOfWorkToken, + session.SessionExpiresAtUtc, + cancellationToken); _logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username); var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes)); diff --git a/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs b/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs index cac6263..b3cb4f0 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/InboxController.cs @@ -20,6 +20,7 @@ public sealed class InboxController : ControllerBase private readonly GlobalLeaksSessionStore _sessionStore; private readonly PendingGlobalLeaksLoginStore _pendingLoginStore; private readonly GlobalLeaksClient _globalLeaksClient; + private readonly GlobalLeaksSessionKeepAliveService _sessionKeepAliveService; private readonly DenunciaInboxService _inboxService; private readonly IInboxTrackingService _trackingService; private readonly ILogger _logger; @@ -28,6 +29,7 @@ public sealed class InboxController : ControllerBase GlobalLeaksSessionStore sessionStore, PendingGlobalLeaksLoginStore pendingLoginStore, GlobalLeaksClient globalLeaksClient, + GlobalLeaksSessionKeepAliveService sessionKeepAliveService, DenunciaInboxService inboxService, IInboxTrackingService trackingService, ILogger logger) @@ -35,6 +37,7 @@ public sealed class InboxController : ControllerBase _sessionStore = sessionStore; _pendingLoginStore = pendingLoginStore; _globalLeaksClient = globalLeaksClient; + _sessionKeepAliveService = sessionKeepAliveService; _inboxService = inboxService; _trackingService = trackingService; _logger = logger; @@ -131,7 +134,14 @@ public sealed class InboxController : ControllerBase cancellationToken); } - await _sessionStore.UpdateSessionAsync(username, session.Id, session.Role, session.DpopPrivateKey, cancellationToken); + await _sessionStore.UpdateSessionAsync( + username, + session.Id, + session.Role, + session.DpopPrivateKey, + session.ProofOfWorkToken, + session.SessionExpiresAtUtc, + cancellationToken); var stored = await _sessionStore.GetAsync(username, cancellationToken); return Ok(ToDto(stored)); } @@ -152,6 +162,45 @@ public sealed class InboxController : ControllerBase } } + [HttpPost("session/keepalive")] + public async Task> KeepSessionAlive( + CancellationToken cancellationToken) + { + var username = GetUsername(); + + try + { + var session = await _sessionKeepAliveService.KeepAliveAsync(username, cancellationToken); + return Ok(ToDto(session)); + } + catch (GlobalLeaksSessionExpiredException) + { + var session = await _sessionStore.GetAsync(username, cancellationToken); + return Ok(ToDto(session)); + } + catch (GlobalLeaksValidationException ex) + { + _logger.LogWarning( + "No se ha podido mantener activa la sesion GlobalLeaks de {Username}. Status={StatusCode}. Mensaje={Message}", + username, + ex.StatusCode, + ex.Message); + + return StatusCode( + ex.StatusCode is >= 500 and <= 504 + ? StatusCodes.Status503ServiceUnavailable + : StatusCodes.Status502BadGateway, + new ApiError("No se ha podido renovar temporalmente la sesion de GlobalLeaks.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error renovando la sesion GlobalLeaks de {Username}.", username); + return StatusCode( + StatusCodes.Status503ServiceUnavailable, + new ApiError("No se ha podido renovar temporalmente la sesion de GlobalLeaks.")); + } + } + [HttpPost("session/clear")] public async Task ClearSession(CancellationToken cancellationToken) { diff --git a/Antifraude.Net/ApiDenuncias/Program.cs b/Antifraude.Net/ApiDenuncias/Program.cs index d3a249d..2f6e588 100644 --- a/Antifraude.Net/ApiDenuncias/Program.cs +++ b/Antifraude.Net/ApiDenuncias/Program.cs @@ -32,6 +32,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs index 1e98698..bc0cb13 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksClient.cs @@ -16,6 +16,10 @@ namespace ApiDenuncias.Services; public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword, string TokenAnswer); +public sealed record RefreshedGlobalLeaksSession( + GlobalLeaksProofOfWorkToken ProofOfWorkToken, + DateTimeOffset? SessionExpiresAtUtc); + public sealed class GlobalLeaksClient { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -197,6 +201,30 @@ public sealed class GlobalLeaksClient throw new GlobalLeaksValidationException("Login fallido: no se pudo completar la autenticacion.", 502); } + public async Task RefreshSessionAsync( + string sessionId, + string dpopPrivateKey, + GlobalLeaksProofOfWorkToken proofOfWorkToken, + CancellationToken cancellationToken = default) + { + const string path = "/api/auth/session"; + var tokenAnswer = SolveProofOfWork( + proofOfWorkToken.Id, + proofOfWorkToken.Salt, + cancellationToken); + + using var request = CreateAuthenticatedRequest( + HttpMethod.Post, + path, + sessionId, + dpopPrivateKey); + request.Content = CreateJsonContent(new { token = tokenAnswer }); + + using var response = await SendGlRequestAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + return ParseSessionRefresh(body); + } + private async Task PrepareProofOfWorkAsync(string username, CancellationToken cancellationToken) { using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token"); @@ -1529,8 +1557,84 @@ public sealed class GlobalLeaksClient } var role = GetString(root, "role", "user_role", "userRole"); + var proofOfWorkToken = ParseProofOfWorkToken(root); + var sessionExpiresAtUtc = ParseSessionExpiration(root); - return new GlSession(id, username, role, dpopPrivateKey); + return new GlSession( + id, + username, + role, + dpopPrivateKey, + proofOfWorkToken, + sessionExpiresAtUtc); + } + + private static RefreshedGlobalLeaksSession ParseSessionRefresh(string body) + { + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + var proofOfWorkToken = ParseProofOfWorkToken(root) + ?? throw new GlobalLeaksValidationException( + "GlobalLeaks no devolvio el reto necesario para mantener activa la sesion.", + StatusCodes.Status502BadGateway); + + return new RefreshedGlobalLeaksSession( + proofOfWorkToken, + ParseSessionExpiration(root)); + } + + private static GlobalLeaksProofOfWorkToken? ParseProofOfWorkToken(JsonElement root) + { + if (!root.TryGetProperty("token", out var token) || + token.ValueKind != JsonValueKind.Object) + { + return null; + } + + var id = GetString(token, "id"); + var salt = GetString(token, "salt"); + return string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(salt) + ? null + : new GlobalLeaksProofOfWorkToken(id, salt); + } + + private static DateTimeOffset? ParseSessionExpiration(JsonElement root) + { + if (!root.TryGetProperty("session_expiration", out var expiration)) + { + return null; + } + + double unixSeconds; + if (expiration.ValueKind == JsonValueKind.Number) + { + if (!expiration.TryGetDouble(out unixSeconds)) + { + return null; + } + } + else if (expiration.ValueKind == JsonValueKind.String) + { + if (!double.TryParse( + expiration.GetString(), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out unixSeconds)) + { + return null; + } + } + else + { + return null; + } + + if (unixSeconds <= 0 || unixSeconds > DateTimeOffset.MaxValue.ToUnixTimeSeconds()) + { + return null; + } + + return DateTimeOffset.FromUnixTimeMilliseconds((long)(unixSeconds * 1000)); } private static async Task EnsureSuccessOrThrowAsync( diff --git a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionKeepAliveService.cs b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionKeepAliveService.cs new file mode 100644 index 0000000..f4d7d92 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionKeepAliveService.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; +using GestionaDenuncias.Shared.Models; + +namespace ApiDenuncias.Services; + +public sealed class GlobalLeaksSessionKeepAliveService +{ + private static readonly TimeSpan MinimumRefreshInterval = TimeSpan.FromSeconds(20); + private static readonly ConcurrentDictionary UserGates = + new(StringComparer.OrdinalIgnoreCase); + + private readonly GlobalLeaksSessionStore _sessionStore; + private readonly GlobalLeaksClient _globalLeaksClient; + private readonly ILogger _logger; + + public GlobalLeaksSessionKeepAliveService( + GlobalLeaksSessionStore sessionStore, + GlobalLeaksClient globalLeaksClient, + ILogger logger) + { + _sessionStore = sessionStore; + _globalLeaksClient = globalLeaksClient; + _logger = logger; + } + + public async Task KeepAliveAsync( + string username, + CancellationToken cancellationToken = default) + { + var gate = UserGates.GetOrAdd(username, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + try + { + var session = await _sessionStore.GetAsync(username, cancellationToken); + if (session?.HasActiveSession != true) + { + return session; + } + + if (session.LastKeepAliveAtUtc is { } lastKeepAlive && + DateTimeOffset.UtcNow - lastKeepAlive < MinimumRefreshInterval) + { + return session; + } + + var expectedSessionId = session.SessionId!; + try + { + var refreshed = await _globalLeaksClient.RefreshSessionAsync( + expectedSessionId, + session.DpopPrivateKey!, + session.ProofOfWorkToken!, + cancellationToken); + + var updated = await _sessionStore.UpdateKeepAliveAsync( + username, + expectedSessionId, + refreshed.ProofOfWorkToken, + refreshed.SessionExpiresAtUtc, + cancellationToken); + + if (updated) + { + _logger.LogDebug("Sesion GlobalLeaks renovada para {Username}.", username); + } + + return await _sessionStore.GetAsync(username, cancellationToken); + } + catch (GlobalLeaksSessionExpiredException) + { + await _sessionStore.ClearSessionIfMatchesAsync( + username, + expectedSessionId, + cancellationToken); + throw; + } + } + finally + { + gate.Release(); + } + } +} diff --git a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionStore.cs b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionStore.cs index 586151a..79208f1 100644 --- a/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionStore.cs +++ b/Antifraude.Net/ApiDenuncias/Services/GlobalLeaksSessionStore.cs @@ -30,18 +30,10 @@ public sealed class GlobalLeaksSessionStore } var path = GetFilePath(username); - if (!File.Exists(path)) - { - return null; - } - await _gate.WaitAsync(cancellationToken); try { - var protectedBytes = await File.ReadAllBytesAsync(path, cancellationToken); - var protectedBase64 = Encoding.UTF8.GetString(protectedBytes); - var json = _protector.Unprotect(protectedBase64); - return JsonSerializer.Deserialize(json, JsonOptions); + return await ReadUnsafeAsync(path, cancellationToken); } finally { @@ -55,8 +47,11 @@ public sealed class GlobalLeaksSessionStore string sessionId, string? role, string? dpopPrivateKey, + GlobalLeaksProofOfWorkToken? proofOfWorkToken, + DateTimeOffset? sessionExpiresAtUtc, CancellationToken cancellationToken = default) { + var now = DateTimeOffset.UtcNow; var data = new GlobalLeaksStoredSession { Username = username, @@ -64,7 +59,10 @@ public sealed class GlobalLeaksSessionStore SessionId = sessionId, Role = role, DpopPrivateKey = dpopPrivateKey, - UpdatedAt = DateTimeOffset.UtcNow, + ProofOfWorkToken = proofOfWorkToken, + SessionExpiresAtUtc = sessionExpiresAtUtc, + LastKeepAliveAtUtc = now, + UpdatedAt = now, }; await WriteAsync(data, cancellationToken); @@ -75,31 +73,115 @@ public sealed class GlobalLeaksSessionStore string sessionId, string? role, string? dpopPrivateKey, + GlobalLeaksProofOfWorkToken? proofOfWorkToken, + DateTimeOffset? sessionExpiresAtUtc, CancellationToken cancellationToken = default) { - var current = await GetAsync(username, cancellationToken) - ?? throw new InvalidOperationException("No hay credenciales guardadas para este usuario."); + var path = GetFilePath(username); - current.SessionId = sessionId; - current.Role = role; - current.DpopPrivateKey = dpopPrivateKey; - current.UpdatedAt = DateTimeOffset.UtcNow; + await _gate.WaitAsync(cancellationToken); + try + { + var current = await ReadUnsafeAsync(path, cancellationToken) + ?? throw new InvalidOperationException("No hay credenciales guardadas para este usuario."); + var now = DateTimeOffset.UtcNow; - await WriteAsync(current, cancellationToken); + current.SessionId = sessionId; + current.Role = role; + current.DpopPrivateKey = dpopPrivateKey; + current.ProofOfWorkToken = proofOfWorkToken; + current.SessionExpiresAtUtc = sessionExpiresAtUtc; + current.LastKeepAliveAtUtc = now; + current.UpdatedAt = now; + + await WriteUnsafeAsync(path, current, cancellationToken); + } + finally + { + _gate.Release(); + } + } + + public async Task UpdateKeepAliveAsync( + string username, + string expectedSessionId, + GlobalLeaksProofOfWorkToken proofOfWorkToken, + DateTimeOffset? sessionExpiresAtUtc, + CancellationToken cancellationToken = default) + { + var path = GetFilePath(username); + + await _gate.WaitAsync(cancellationToken); + try + { + var current = await ReadUnsafeAsync(path, cancellationToken); + if (current is null || + !string.Equals(current.SessionId, expectedSessionId, StringComparison.Ordinal)) + { + return false; + } + + var now = DateTimeOffset.UtcNow; + current.ProofOfWorkToken = proofOfWorkToken; + current.SessionExpiresAtUtc = sessionExpiresAtUtc; + current.LastKeepAliveAtUtc = now; + current.UpdatedAt = now; + await WriteUnsafeAsync(path, current, cancellationToken); + return true; + } + finally + { + _gate.Release(); + } } public async Task ClearSessionAsync(string username, CancellationToken cancellationToken = default) { - var current = await GetAsync(username, cancellationToken); - if (current is null) - { - return; - } + var path = GetFilePath(username); - current.SessionId = null; - current.DpopPrivateKey = null; - current.UpdatedAt = DateTimeOffset.UtcNow; - await WriteAsync(current, cancellationToken); + await _gate.WaitAsync(cancellationToken); + try + { + var current = await ReadUnsafeAsync(path, cancellationToken); + if (current is null) + { + return; + } + + ClearSessionValues(current); + await WriteUnsafeAsync(path, current, cancellationToken); + } + finally + { + _gate.Release(); + } + } + + public async Task ClearSessionIfMatchesAsync( + string username, + string expectedSessionId, + CancellationToken cancellationToken = default) + { + var path = GetFilePath(username); + + await _gate.WaitAsync(cancellationToken); + try + { + var current = await ReadUnsafeAsync(path, cancellationToken); + if (current is null || + !string.Equals(current.SessionId, expectedSessionId, StringComparison.Ordinal)) + { + return false; + } + + ClearSessionValues(current); + await WriteUnsafeAsync(path, current, cancellationToken); + return true; + } + finally + { + _gate.Release(); + } } public async Task DeleteAsync(string username, CancellationToken cancellationToken = default) @@ -127,17 +209,12 @@ public sealed class GlobalLeaksSessionStore private async Task WriteAsync(GlobalLeaksStoredSession data, CancellationToken cancellationToken) { - Directory.CreateDirectory(RootPath); - var path = GetFilePath(data.Username); - var json = JsonSerializer.Serialize(data, JsonOptions); - var protectedValue = _protector.Protect(json); - var protectedBytes = Encoding.UTF8.GetBytes(protectedValue); await _gate.WaitAsync(cancellationToken); try { - await File.WriteAllBytesAsync(path, protectedBytes, cancellationToken); + await WriteUnsafeAsync(path, data, cancellationToken); } finally { @@ -152,4 +229,41 @@ public sealed class GlobalLeaksSessionStore var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); return Path.Combine(RootPath, $"{Convert.ToHexString(hash).ToLowerInvariant()}.bin"); } + + private async Task ReadUnsafeAsync( + string path, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return null; + } + + var protectedBytes = await File.ReadAllBytesAsync(path, cancellationToken); + var protectedBase64 = Encoding.UTF8.GetString(protectedBytes); + var json = _protector.Unprotect(protectedBase64); + return JsonSerializer.Deserialize(json, JsonOptions); + } + + private async Task WriteUnsafeAsync( + string path, + GlobalLeaksStoredSession data, + CancellationToken cancellationToken) + { + Directory.CreateDirectory(RootPath); + var json = JsonSerializer.Serialize(data, JsonOptions); + var protectedValue = _protector.Protect(json); + var protectedBytes = Encoding.UTF8.GetBytes(protectedValue); + await File.WriteAllBytesAsync(path, protectedBytes, cancellationToken); + } + + private static void ClearSessionValues(GlobalLeaksStoredSession session) + { + session.SessionId = null; + session.DpopPrivateKey = null; + session.ProofOfWorkToken = null; + session.SessionExpiresAtUtc = null; + session.LastKeepAliveAtUtc = null; + session.UpdatedAt = DateTimeOffset.UtcNow; + } } diff --git a/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs b/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs index 2d4bc32..6d294d6 100644 --- a/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs +++ b/Antifraude.Net/ApiDenuncias/Services/InboxTrackingService.cs @@ -541,14 +541,13 @@ public sealed class InboxTrackingService : IInboxTrackingService WHEN ir.owner_user_id IS NULL THEN 0 WHEN EXISTS ( SELECT 1 - FROM app_user_groups current_group - INNER JOIN app_user_groups owner_group - ON owner_group.work_group_id = current_group.work_group_id + FROM app_user_groups shared_membership INNER JOIN work_groups active_group - ON active_group.id = current_group.work_group_id + ON active_group.id = shared_membership.work_group_id AND active_group.is_active = 1 - WHERE current_group.app_user_id = @userId - AND owner_group.app_user_id = ir.owner_user_id + WHERE shared_membership.app_user_id IN (@userId, ir.owner_user_id) + GROUP BY shared_membership.work_group_id + HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2 ) THEN 1 ELSE 0 END AS accessible_by_group, diff --git a/Antifraude.Net/ApiDenuncias/Services/UserComplaintAccessService.cs b/Antifraude.Net/ApiDenuncias/Services/UserComplaintAccessService.cs index 891ec97..2133182 100644 --- a/Antifraude.Net/ApiDenuncias/Services/UserComplaintAccessService.cs +++ b/Antifraude.Net/ApiDenuncias/Services/UserComplaintAccessService.cs @@ -61,19 +61,18 @@ public sealed class UserComplaintAccessService SELECT COALESCE(ir.imported_complaint_report_id, ir.progressive_id) AS complaint_id, owner.username AS owner_username, - CASE WHEN ir.owner_user_id = current_user.id THEN 1 ELSE 0 END AS owned_by_current_user, + CASE WHEN ir.owner_user_id = viewer.id THEN 1 ELSE 0 END AS owned_by_current_user, CASE WHEN ir.owner_user_id IS NULL THEN 0 WHEN EXISTS ( SELECT 1 - FROM app_user_groups current_group - INNER JOIN app_user_groups owner_group - ON owner_group.work_group_id = current_group.work_group_id + FROM app_user_groups shared_membership INNER JOIN work_groups active_group - ON active_group.id = current_group.work_group_id + ON active_group.id = shared_membership.work_group_id AND active_group.is_active = 1 - WHERE current_group.app_user_id = current_user.id - AND owner_group.app_user_id = ir.owner_user_id + WHERE shared_membership.app_user_id IN (viewer.id, ir.owner_user_id) + GROUP BY shared_membership.work_group_id + HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2 ) THEN 1 ELSE 0 END AS accessible_by_group, @@ -85,27 +84,26 @@ public sealed class UserComplaintAccessService AND wg.is_active = 1 WHERE owner_membership.app_user_id = ir.owner_user_id ) AS owner_group_codes - FROM app_users current_user + FROM app_users viewer INNER JOIN inbox_reports ir ON COALESCE(ir.imported_complaint_report_id, ir.progressive_id) IS NOT NULL LEFT JOIN app_users owner ON owner.id = ir.owner_user_id LEFT JOIN user_inbox_reports current_tracking ON current_tracking.inbox_report_id = ir.id - AND current_tracking.app_user_id = current_user.id - WHERE current_user.username = @username + AND current_tracking.app_user_id = viewer.id + WHERE viewer.username = @username {idFilter} AND ( - ir.owner_user_id = current_user.id + ir.owner_user_id = viewer.id OR EXISTS ( SELECT 1 - FROM app_user_groups current_group - INNER JOIN app_user_groups owner_group - ON owner_group.work_group_id = current_group.work_group_id + FROM app_user_groups shared_membership INNER JOIN work_groups active_group - ON active_group.id = current_group.work_group_id + ON active_group.id = shared_membership.work_group_id AND active_group.is_active = 1 - WHERE current_group.app_user_id = current_user.id - AND owner_group.app_user_id = ir.owner_user_id + WHERE shared_membership.app_user_id IN (viewer.id, ir.owner_user_id) + GROUP BY shared_membership.work_group_id + HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2 ) OR ( ir.owner_user_id IS NULL diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/GlSession.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/GlSession.cs index cc20a2a..90263dc 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/GlSession.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/GlSession.cs @@ -1,3 +1,11 @@ namespace GestionaDenuncias.Shared.Models; -public sealed record GlSession(string Id, string Username, string? Role = null, string? DpopPrivateKey = null); +public sealed record GlobalLeaksProofOfWorkToken(string Id, string Salt); + +public sealed record GlSession( + string Id, + string Username, + string? Role = null, + string? DpopPrivateKey = null, + GlobalLeaksProofOfWorkToken? ProofOfWorkToken = null, + DateTimeOffset? SessionExpiresAtUtc = null); diff --git a/Antifraude.Net/GestionaDenuncias.Shared/Models/GlobalLeaksStoredSession.cs b/Antifraude.Net/GestionaDenuncias.Shared/Models/GlobalLeaksStoredSession.cs index c214798..959a784 100644 --- a/Antifraude.Net/GestionaDenuncias.Shared/Models/GlobalLeaksStoredSession.cs +++ b/Antifraude.Net/GestionaDenuncias.Shared/Models/GlobalLeaksStoredSession.cs @@ -7,9 +7,14 @@ public sealed class GlobalLeaksStoredSession public string? SessionId { get; set; } public string? Role { get; set; } public string? DpopPrivateKey { get; set; } + public GlobalLeaksProofOfWorkToken? ProofOfWorkToken { get; set; } + public DateTimeOffset? SessionExpiresAtUtc { get; set; } + public DateTimeOffset? LastKeepAliveAtUtc { get; set; } public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public bool HasActiveSession => !string.IsNullOrWhiteSpace(SessionId) && - !string.IsNullOrWhiteSpace(DpopPrivateKey); + !string.IsNullOrWhiteSpace(DpopPrivateKey) && + !string.IsNullOrWhiteSpace(ProofOfWorkToken?.Id) && + !string.IsNullOrWhiteSpace(ProofOfWorkToken?.Salt); } diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/App.razor b/Antifraude.Net/GestionaDenunciasAN/Components/App.razor index 3d60f55..9e67e99 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/App.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/App.razor @@ -17,7 +17,7 @@ - + diff --git a/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor b/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor index 2543400..1738cf9 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor +++ b/Antifraude.Net/GestionaDenunciasAN/Components/Layout/MainLayout.razor @@ -1,5 +1,5 @@ @inherits LayoutComponentBase -@implements IDisposable +@implements IAsyncDisposable @using System.Globalization @inject GestionaDenunciasAN.Models.UserState userState @inject IHttpContextAccessor HttpContextAccessor @@ -7,6 +7,7 @@ @inject NavigationManager Navigation @inject UiBusyService Busy @inject ApiDenunciasClient ApiDenuncias +@inject ILogger Logger
@code { + private static readonly TimeSpan GlobalLeaksHeartbeatInterval = TimeSpan.FromSeconds(30); + private static readonly TimeSpan GlobalLeaksIdleTimeout = TimeSpan.FromMinutes(30); + private string CurrentPageTitle { get; set; } = "Portal de gestion"; private string CurrentPageDescription { get; set; } = "Entrada, revision y tramitacion coordinada de denuncias y actualizaciones."; private string EncryptionKeyText { get; set; } = "Clave diaria: cargando..."; private string EncryptionKeyTooltip { get; set; } = "Consultando la ultima clave diaria de cifrado activa en la API."; private string EncryptionKeyPillCss { get; set; } = "app-session-pill app-key-pill"; + private readonly CancellationTokenSource _heartbeatCancellation = new(); + private PeriodicTimer? _heartbeatTimer; + private Task? _heartbeatTask; + private bool _globalLeaksSessionClearedForIdle; + private DateTimeOffset _lastHeartbeatWarningAtUtc = DateTimeOffset.MinValue; private string DisplayUsername => string.IsNullOrWhiteSpace(userState?.NombreUsu) @@ -87,9 +96,58 @@ RefreshLayoutState(); } - public void Dispose() + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + try + { + await JSRuntime.InvokeVoidAsync("appGlobalLeaksActivity.start"); + } + catch (JSException ex) + { + Logger.LogError( + ex, + "No se ha podido iniciar el control de actividad para la sesion GlobalLeaks."); + return; + } + + _heartbeatTimer = new PeriodicTimer(GlobalLeaksHeartbeatInterval); + _heartbeatTask = RunGlobalLeaksHeartbeatAsync(_heartbeatCancellation.Token); + } + + public async ValueTask DisposeAsync() { Navigation.LocationChanged -= HandleLocationChanged; + _heartbeatCancellation.Cancel(); + _heartbeatTimer?.Dispose(); + + if (_heartbeatTask is not null) + { + try + { + await _heartbeatTask; + } + catch (OperationCanceledException) + { + } + } + + try + { + await JSRuntime.InvokeVoidAsync("appGlobalLeaksActivity.stop"); + } + catch (JSDisconnectedException) + { + } + catch (ObjectDisposedException) + { + } + + _heartbeatCancellation.Dispose(); } private void HandleLocationChanged(object? sender, LocationChangedEventArgs args) @@ -134,6 +192,69 @@ } } + private async Task RunGlobalLeaksHeartbeatAsync(CancellationToken cancellationToken) + { + try + { + while (_heartbeatTimer is not null && + await _heartbeatTimer.WaitForNextTickAsync(cancellationToken)) + { + await InvokeAsync(() => MaintainGlobalLeaksSessionAsync(cancellationToken)); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (ObjectDisposedException) + { + } + } + + private async Task MaintainGlobalLeaksSessionAsync(CancellationToken cancellationToken) + { + try + { + var idleMilliseconds = await JSRuntime.InvokeAsync( + "appGlobalLeaksActivity.getIdleMilliseconds", + cancellationToken); + + if (idleMilliseconds >= GlobalLeaksIdleTimeout.TotalMilliseconds) + { + if (!_globalLeaksSessionClearedForIdle) + { + await ApiDenuncias.ClearGlobalLeaksSessionAsync(cancellationToken); + _globalLeaksSessionClearedForIdle = true; + } + + return; + } + + _globalLeaksSessionClearedForIdle = false; + await ApiDenuncias.KeepGlobalLeaksSessionAliveAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (JSDisconnectedException) + { + } + catch (UnauthorizedAccessException) + { + _heartbeatCancellation.Cancel(); + } + catch (Exception ex) + { + var now = DateTimeOffset.UtcNow; + if (now - _lastHeartbeatWarningAtUtc >= TimeSpan.FromMinutes(5)) + { + _lastHeartbeatWarningAtUtc = now; + Logger.LogWarning( + ex, + "No se ha podido ejecutar el mantenimiento de la sesion GlobalLeaks."); + } + } + } + private static string ToSpanishDateText(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/Antifraude.Net/GestionaDenunciasAN/Services/ApiDenunciasClient.cs b/Antifraude.Net/GestionaDenunciasAN/Services/ApiDenunciasClient.cs index 1f54f89..6a00bc6 100644 --- a/Antifraude.Net/GestionaDenunciasAN/Services/ApiDenunciasClient.cs +++ b/Antifraude.Net/GestionaDenunciasAN/Services/ApiDenunciasClient.cs @@ -47,6 +47,15 @@ public sealed class ApiDenunciasClient public Task GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default) => SendAsync(HttpMethod.Get, "api/inbox/session", body: null, authorize: true, cancellationToken, allowNull: true); + public Task KeepGlobalLeaksSessionAliveAsync(CancellationToken cancellationToken = default) + => SendAsync( + HttpMethod.Post, + "api/inbox/session/keepalive", + body: null, + authorize: true, + cancellationToken, + allowNull: true); + public Task PrepareGlobalLeaksSessionRenewalAsync(CancellationToken cancellationToken = default) => SendAsync( HttpMethod.Post, diff --git a/Antifraude.Net/GestionaDenunciasAN/wwwroot/js/appAuth.js b/Antifraude.Net/GestionaDenunciasAN/wwwroot/js/appAuth.js index e0121a5..c28096c 100644 --- a/Antifraude.Net/GestionaDenunciasAN/wwwroot/js/appAuth.js +++ b/Antifraude.Net/GestionaDenunciasAN/wwwroot/js/appAuth.js @@ -68,3 +68,62 @@ window.appSetBodyScrollLock = function (locked) { document.documentElement.classList.toggle("app-scroll-locked", Boolean(locked)); document.body.classList.toggle("app-scroll-locked", Boolean(locked)); }; + +window.appGlobalLeaksActivity = (function () { + const storageKey = "gestiona-denuncias:last-user-activity"; + const events = ["pointerdown", "pointermove", "keydown", "touchstart", "scroll"]; + let lastActivity = Date.now(); + let tracking = false; + + function readSharedActivity() { + try { + const stored = Number(window.localStorage.getItem(storageKey)); + return Number.isFinite(stored) && stored > 0 ? stored : 0; + } catch { + return 0; + } + } + + function markActivity() { + lastActivity = Date.now(); + try { + window.localStorage.setItem(storageKey, String(lastActivity)); + } catch { + // The in-memory timestamp still works when local storage is unavailable. + } + } + + function start() { + if (tracking) { + return; + } + + tracking = true; + markActivity(); + for (const eventName of events) { + window.addEventListener(eventName, markActivity, { passive: true }); + } + } + + function stop() { + if (!tracking) { + return; + } + + tracking = false; + for (const eventName of events) { + window.removeEventListener(eventName, markActivity); + } + } + + function getIdleMilliseconds() { + const latestActivity = Math.max(lastActivity, readSharedActivity()); + return Math.max(0, Date.now() - latestActivity); + } + + return { + start, + stop, + getIdleMilliseconds + }; +})(); diff --git a/Antifraude.Net/RegistroPersonalAN/Services/CertificateLoginBridgeService.cs b/Antifraude.Net/RegistroPersonalAN/Services/CertificateLoginBridgeService.cs index a190315..95285f5 100644 --- a/Antifraude.Net/RegistroPersonalAN/Services/CertificateLoginBridgeService.cs +++ b/Antifraude.Net/RegistroPersonalAN/Services/CertificateLoginBridgeService.cs @@ -55,7 +55,8 @@ namespace RegistroPersonalAN.Services var client = _httpClientFactory.CreateClient("DefaultClient"); using var response = await client.PostAsJsonAsync("Auth/login-cert-proxy", new CertificateProxyLoginRequest { - Dni = dni + Dni = dni, + Origen = "Registro" }); var responseContent = await response.Content.ReadAsStringAsync(); @@ -96,6 +97,7 @@ namespace RegistroPersonalAN.Services public sealed class CertificateProxyLoginRequest { public string Dni { get; set; } = string.Empty; + public string Origen { get; set; } = string.Empty; } public sealed class CertificateProxyLoginResponse