cambiso en denuncias, mejoras de 30 min en inicio de sesion e incorporacion de grupos

Cambiso de certificado en registro de personal para aañadir el origen
This commit is contained in:
2026-07-27 08:46:41 +02:00
parent dd31460cf0
commit 6f9392f3d0
15 changed files with 624 additions and 64 deletions

View File

@@ -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));

View File

@@ -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<InboxController> _logger;
@@ -28,6 +29,7 @@ public sealed class InboxController : ControllerBase
GlobalLeaksSessionStore sessionStore,
PendingGlobalLeaksLoginStore pendingLoginStore,
GlobalLeaksClient globalLeaksClient,
GlobalLeaksSessionKeepAliveService sessionKeepAliveService,
DenunciaInboxService inboxService,
IInboxTrackingService trackingService,
ILogger<InboxController> 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<ActionResult<ApiGlobalLeaksSessionDto?>> 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<IActionResult> ClearSession(CancellationToken cancellationToken)
{

View File

@@ -32,6 +32,7 @@ builder.Services.AddSingleton<LoginRateLimiter>();
builder.Services.AddSingleton<GlobalLeaksSessionStore>();
builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>();
builder.Services.AddScoped<GlobalLeaksClient>();
builder.Services.AddScoped<GlobalLeaksSessionKeepAliveService>();
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
builder.Services.AddScoped<MySqlDenunciaStore>();
builder.Services.AddSingleton<IEncryptionKeyProvider, KeyVaultEncryptionKeyProvider>();

View File

@@ -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<RefreshedGlobalLeaksSession> 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<string> 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(

View File

@@ -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<string, SemaphoreSlim> UserGates =
new(StringComparer.OrdinalIgnoreCase);
private readonly GlobalLeaksSessionStore _sessionStore;
private readonly GlobalLeaksClient _globalLeaksClient;
private readonly ILogger<GlobalLeaksSessionKeepAliveService> _logger;
public GlobalLeaksSessionKeepAliveService(
GlobalLeaksSessionStore sessionStore,
GlobalLeaksClient globalLeaksClient,
ILogger<GlobalLeaksSessionKeepAliveService> logger)
{
_sessionStore = sessionStore;
_globalLeaksClient = globalLeaksClient;
_logger = logger;
}
public async Task<GlobalLeaksStoredSession?> 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();
}
}
}

View File

@@ -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<GlobalLeaksStoredSession>(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)
var path = GetFilePath(username);
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;
current.SessionId = sessionId;
current.Role = role;
current.DpopPrivateKey = dpopPrivateKey;
current.UpdatedAt = DateTimeOffset.UtcNow;
current.ProofOfWorkToken = proofOfWorkToken;
current.SessionExpiresAtUtc = sessionExpiresAtUtc;
current.LastKeepAliveAtUtc = now;
current.UpdatedAt = now;
await WriteAsync(current, cancellationToken);
await WriteUnsafeAsync(path, current, cancellationToken);
}
finally
{
_gate.Release();
}
}
public async Task<bool> 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);
var path = GetFilePath(username);
await _gate.WaitAsync(cancellationToken);
try
{
var current = await ReadUnsafeAsync(path, cancellationToken);
if (current is null)
{
return;
}
current.SessionId = null;
current.DpopPrivateKey = null;
current.UpdatedAt = DateTimeOffset.UtcNow;
await WriteAsync(current, cancellationToken);
ClearSessionValues(current);
await WriteUnsafeAsync(path, current, cancellationToken);
}
finally
{
_gate.Release();
}
}
public async Task<bool> 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<GlobalLeaksStoredSession?> 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<GlobalLeaksStoredSession>(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;
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -17,7 +17,7 @@
<body>
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
<script src="Scripts/bootstrap.bundle.min.js"></script>
<script src="js/appAuth.js"></script>
<script src="js/appAuth.js?v=20260724-session-keepalive"></script>
<script src="_framework/blazor.web.js"></script>
</body>

View File

@@ -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<MainLayout> Logger
<div class="app-shell">
<aside class="app-sidebar">
@@ -59,12 +60,20 @@
</div>
@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<double>(
"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))

View File

@@ -47,6 +47,15 @@ public sealed class ApiDenunciasClient
public Task<ApiGlobalLeaksSessionDto?> GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiGlobalLeaksSessionDto?>(HttpMethod.Get, "api/inbox/session", body: null, authorize: true, cancellationToken, allowNull: true);
public Task<ApiGlobalLeaksSessionDto?> KeepGlobalLeaksSessionAliveAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiGlobalLeaksSessionDto?>(
HttpMethod.Post,
"api/inbox/session/keepalive",
body: null,
authorize: true,
cancellationToken,
allowNull: true);
public Task<ApiLoginPrepareResponse> PrepareGlobalLeaksSessionRenewalAsync(CancellationToken cancellationToken = default)
=> SendAsync<ApiLoginPrepareResponse>(
HttpMethod.Post,

View File

@@ -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
};
})();

View File

@@ -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