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:
@@ -304,7 +304,15 @@ public sealed class AuthController : ControllerBase
|
|||||||
: session.Username.Trim();
|
: session.Username.Trim();
|
||||||
|
|
||||||
_logger.LogInformation("Login GlobalLeaks validado para {Username}. Guardando sesion cifrada.", username);
|
_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);
|
_logger.LogInformation("Sesion GlobalLeaks guardada para {Username}. Generando JWT.", username);
|
||||||
|
|
||||||
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));
|
var expiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(Math.Max(5, _jwtOptions.ExpirationMinutes));
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class InboxController : ControllerBase
|
|||||||
private readonly GlobalLeaksSessionStore _sessionStore;
|
private readonly GlobalLeaksSessionStore _sessionStore;
|
||||||
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
|
private readonly PendingGlobalLeaksLoginStore _pendingLoginStore;
|
||||||
private readonly GlobalLeaksClient _globalLeaksClient;
|
private readonly GlobalLeaksClient _globalLeaksClient;
|
||||||
|
private readonly GlobalLeaksSessionKeepAliveService _sessionKeepAliveService;
|
||||||
private readonly DenunciaInboxService _inboxService;
|
private readonly DenunciaInboxService _inboxService;
|
||||||
private readonly IInboxTrackingService _trackingService;
|
private readonly IInboxTrackingService _trackingService;
|
||||||
private readonly ILogger<InboxController> _logger;
|
private readonly ILogger<InboxController> _logger;
|
||||||
@@ -28,6 +29,7 @@ public sealed class InboxController : ControllerBase
|
|||||||
GlobalLeaksSessionStore sessionStore,
|
GlobalLeaksSessionStore sessionStore,
|
||||||
PendingGlobalLeaksLoginStore pendingLoginStore,
|
PendingGlobalLeaksLoginStore pendingLoginStore,
|
||||||
GlobalLeaksClient globalLeaksClient,
|
GlobalLeaksClient globalLeaksClient,
|
||||||
|
GlobalLeaksSessionKeepAliveService sessionKeepAliveService,
|
||||||
DenunciaInboxService inboxService,
|
DenunciaInboxService inboxService,
|
||||||
IInboxTrackingService trackingService,
|
IInboxTrackingService trackingService,
|
||||||
ILogger<InboxController> logger)
|
ILogger<InboxController> logger)
|
||||||
@@ -35,6 +37,7 @@ public sealed class InboxController : ControllerBase
|
|||||||
_sessionStore = sessionStore;
|
_sessionStore = sessionStore;
|
||||||
_pendingLoginStore = pendingLoginStore;
|
_pendingLoginStore = pendingLoginStore;
|
||||||
_globalLeaksClient = globalLeaksClient;
|
_globalLeaksClient = globalLeaksClient;
|
||||||
|
_sessionKeepAliveService = sessionKeepAliveService;
|
||||||
_inboxService = inboxService;
|
_inboxService = inboxService;
|
||||||
_trackingService = trackingService;
|
_trackingService = trackingService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -131,7 +134,14 @@ public sealed class InboxController : ControllerBase
|
|||||||
cancellationToken);
|
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);
|
var stored = await _sessionStore.GetAsync(username, cancellationToken);
|
||||||
return Ok(ToDto(stored));
|
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")]
|
[HttpPost("session/clear")]
|
||||||
public async Task<IActionResult> ClearSession(CancellationToken cancellationToken)
|
public async Task<IActionResult> ClearSession(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ builder.Services.AddSingleton<LoginRateLimiter>();
|
|||||||
builder.Services.AddSingleton<GlobalLeaksSessionStore>();
|
builder.Services.AddSingleton<GlobalLeaksSessionStore>();
|
||||||
builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>();
|
builder.Services.AddSingleton<PendingGlobalLeaksLoginStore>();
|
||||||
builder.Services.AddScoped<GlobalLeaksClient>();
|
builder.Services.AddScoped<GlobalLeaksClient>();
|
||||||
|
builder.Services.AddScoped<GlobalLeaksSessionKeepAliveService>();
|
||||||
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
|
builder.Services.AddSingleton<MySqlConnectionStringProvider>();
|
||||||
builder.Services.AddScoped<MySqlDenunciaStore>();
|
builder.Services.AddScoped<MySqlDenunciaStore>();
|
||||||
builder.Services.AddSingleton<IEncryptionKeyProvider, KeyVaultEncryptionKeyProvider>();
|
builder.Services.AddSingleton<IEncryptionKeyProvider, KeyVaultEncryptionKeyProvider>();
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ namespace ApiDenuncias.Services;
|
|||||||
|
|
||||||
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword, string TokenAnswer);
|
public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword, string TokenAnswer);
|
||||||
|
|
||||||
|
public sealed record RefreshedGlobalLeaksSession(
|
||||||
|
GlobalLeaksProofOfWorkToken ProofOfWorkToken,
|
||||||
|
DateTimeOffset? SessionExpiresAtUtc);
|
||||||
|
|
||||||
public sealed class GlobalLeaksClient
|
public sealed class GlobalLeaksClient
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
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);
|
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)
|
private async Task<string> PrepareProofOfWorkAsync(string username, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
|
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 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(
|
private static async Task EnsureSuccessOrThrowAsync(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,18 +30,10 @@ public sealed class GlobalLeaksSessionStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
var path = GetFilePath(username);
|
var path = GetFilePath(username);
|
||||||
if (!File.Exists(path))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _gate.WaitAsync(cancellationToken);
|
await _gate.WaitAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var protectedBytes = await File.ReadAllBytesAsync(path, cancellationToken);
|
return await ReadUnsafeAsync(path, cancellationToken);
|
||||||
var protectedBase64 = Encoding.UTF8.GetString(protectedBytes);
|
|
||||||
var json = _protector.Unprotect(protectedBase64);
|
|
||||||
return JsonSerializer.Deserialize<GlobalLeaksStoredSession>(json, JsonOptions);
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -55,8 +47,11 @@ public sealed class GlobalLeaksSessionStore
|
|||||||
string sessionId,
|
string sessionId,
|
||||||
string? role,
|
string? role,
|
||||||
string? dpopPrivateKey,
|
string? dpopPrivateKey,
|
||||||
|
GlobalLeaksProofOfWorkToken? proofOfWorkToken,
|
||||||
|
DateTimeOffset? sessionExpiresAtUtc,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
var data = new GlobalLeaksStoredSession
|
var data = new GlobalLeaksStoredSession
|
||||||
{
|
{
|
||||||
Username = username,
|
Username = username,
|
||||||
@@ -64,7 +59,10 @@ public sealed class GlobalLeaksSessionStore
|
|||||||
SessionId = sessionId,
|
SessionId = sessionId,
|
||||||
Role = role,
|
Role = role,
|
||||||
DpopPrivateKey = dpopPrivateKey,
|
DpopPrivateKey = dpopPrivateKey,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow,
|
ProofOfWorkToken = proofOfWorkToken,
|
||||||
|
SessionExpiresAtUtc = sessionExpiresAtUtc,
|
||||||
|
LastKeepAliveAtUtc = now,
|
||||||
|
UpdatedAt = now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await WriteAsync(data, cancellationToken);
|
await WriteAsync(data, cancellationToken);
|
||||||
@@ -75,31 +73,115 @@ public sealed class GlobalLeaksSessionStore
|
|||||||
string sessionId,
|
string sessionId,
|
||||||
string? role,
|
string? role,
|
||||||
string? dpopPrivateKey,
|
string? dpopPrivateKey,
|
||||||
|
GlobalLeaksProofOfWorkToken? proofOfWorkToken,
|
||||||
|
DateTimeOffset? sessionExpiresAtUtc,
|
||||||
CancellationToken cancellationToken = default)
|
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.");
|
?? throw new InvalidOperationException("No hay credenciales guardadas para este usuario.");
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
current.SessionId = sessionId;
|
current.SessionId = sessionId;
|
||||||
current.Role = role;
|
current.Role = role;
|
||||||
current.DpopPrivateKey = dpopPrivateKey;
|
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)
|
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)
|
if (current is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
current.SessionId = null;
|
ClearSessionValues(current);
|
||||||
current.DpopPrivateKey = null;
|
await WriteUnsafeAsync(path, current, cancellationToken);
|
||||||
current.UpdatedAt = DateTimeOffset.UtcNow;
|
}
|
||||||
await WriteAsync(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)
|
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)
|
private async Task WriteAsync(GlobalLeaksStoredSession data, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(RootPath);
|
|
||||||
|
|
||||||
var path = GetFilePath(data.Username);
|
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);
|
await _gate.WaitAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await File.WriteAllBytesAsync(path, protectedBytes, cancellationToken);
|
await WriteUnsafeAsync(path, data, cancellationToken);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -152,4 +229,41 @@ public sealed class GlobalLeaksSessionStore
|
|||||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
|
||||||
return Path.Combine(RootPath, $"{Convert.ToHexString(hash).ToLowerInvariant()}.bin");
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -541,14 +541,13 @@ public sealed class InboxTrackingService : IInboxTrackingService
|
|||||||
WHEN ir.owner_user_id IS NULL THEN 0
|
WHEN ir.owner_user_id IS NULL THEN 0
|
||||||
WHEN EXISTS (
|
WHEN EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM app_user_groups current_group
|
FROM app_user_groups shared_membership
|
||||||
INNER JOIN app_user_groups owner_group
|
|
||||||
ON owner_group.work_group_id = current_group.work_group_id
|
|
||||||
INNER JOIN work_groups active_group
|
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
|
AND active_group.is_active = 1
|
||||||
WHERE current_group.app_user_id = @userId
|
WHERE shared_membership.app_user_id IN (@userId, ir.owner_user_id)
|
||||||
AND owner_group.app_user_id = ir.owner_user_id
|
GROUP BY shared_membership.work_group_id
|
||||||
|
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
|
||||||
) THEN 1
|
) THEN 1
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END AS accessible_by_group,
|
END AS accessible_by_group,
|
||||||
|
|||||||
@@ -61,19 +61,18 @@ public sealed class UserComplaintAccessService
|
|||||||
SELECT
|
SELECT
|
||||||
COALESCE(ir.imported_complaint_report_id, ir.progressive_id) AS complaint_id,
|
COALESCE(ir.imported_complaint_report_id, ir.progressive_id) AS complaint_id,
|
||||||
owner.username AS owner_username,
|
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
|
CASE
|
||||||
WHEN ir.owner_user_id IS NULL THEN 0
|
WHEN ir.owner_user_id IS NULL THEN 0
|
||||||
WHEN EXISTS (
|
WHEN EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM app_user_groups current_group
|
FROM app_user_groups shared_membership
|
||||||
INNER JOIN app_user_groups owner_group
|
|
||||||
ON owner_group.work_group_id = current_group.work_group_id
|
|
||||||
INNER JOIN work_groups active_group
|
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
|
AND active_group.is_active = 1
|
||||||
WHERE current_group.app_user_id = current_user.id
|
WHERE shared_membership.app_user_id IN (viewer.id, ir.owner_user_id)
|
||||||
AND owner_group.app_user_id = ir.owner_user_id
|
GROUP BY shared_membership.work_group_id
|
||||||
|
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
|
||||||
) THEN 1
|
) THEN 1
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END AS accessible_by_group,
|
END AS accessible_by_group,
|
||||||
@@ -85,27 +84,26 @@ public sealed class UserComplaintAccessService
|
|||||||
AND wg.is_active = 1
|
AND wg.is_active = 1
|
||||||
WHERE owner_membership.app_user_id = ir.owner_user_id
|
WHERE owner_membership.app_user_id = ir.owner_user_id
|
||||||
) AS owner_group_codes
|
) AS owner_group_codes
|
||||||
FROM app_users current_user
|
FROM app_users viewer
|
||||||
INNER JOIN inbox_reports ir
|
INNER JOIN inbox_reports ir
|
||||||
ON COALESCE(ir.imported_complaint_report_id, ir.progressive_id) IS NOT NULL
|
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 app_users owner ON owner.id = ir.owner_user_id
|
||||||
LEFT JOIN user_inbox_reports current_tracking
|
LEFT JOIN user_inbox_reports current_tracking
|
||||||
ON current_tracking.inbox_report_id = ir.id
|
ON current_tracking.inbox_report_id = ir.id
|
||||||
AND current_tracking.app_user_id = current_user.id
|
AND current_tracking.app_user_id = viewer.id
|
||||||
WHERE current_user.username = @username
|
WHERE viewer.username = @username
|
||||||
{idFilter}
|
{idFilter}
|
||||||
AND (
|
AND (
|
||||||
ir.owner_user_id = current_user.id
|
ir.owner_user_id = viewer.id
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM app_user_groups current_group
|
FROM app_user_groups shared_membership
|
||||||
INNER JOIN app_user_groups owner_group
|
|
||||||
ON owner_group.work_group_id = current_group.work_group_id
|
|
||||||
INNER JOIN work_groups active_group
|
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
|
AND active_group.is_active = 1
|
||||||
WHERE current_group.app_user_id = current_user.id
|
WHERE shared_membership.app_user_id IN (viewer.id, ir.owner_user_id)
|
||||||
AND owner_group.app_user_id = ir.owner_user_id
|
GROUP BY shared_membership.work_group_id
|
||||||
|
HAVING COUNT(DISTINCT shared_membership.app_user_id) = 2
|
||||||
)
|
)
|
||||||
OR (
|
OR (
|
||||||
ir.owner_user_id IS NULL
|
ir.owner_user_id IS NULL
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
namespace GestionaDenuncias.Shared.Models;
|
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);
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ public sealed class GlobalLeaksStoredSession
|
|||||||
public string? SessionId { get; set; }
|
public string? SessionId { get; set; }
|
||||||
public string? Role { get; set; }
|
public string? Role { get; set; }
|
||||||
public string? DpopPrivateKey { 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 DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public bool HasActiveSession =>
|
public bool HasActiveSession =>
|
||||||
!string.IsNullOrWhiteSpace(SessionId) &&
|
!string.IsNullOrWhiteSpace(SessionId) &&
|
||||||
!string.IsNullOrWhiteSpace(DpopPrivateKey);
|
!string.IsNullOrWhiteSpace(DpopPrivateKey) &&
|
||||||
|
!string.IsNullOrWhiteSpace(ProofOfWorkToken?.Id) &&
|
||||||
|
!string.IsNullOrWhiteSpace(ProofOfWorkToken?.Salt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
|
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
|
||||||
<script src="Scripts/bootstrap.bundle.min.js"></script>
|
<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>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@implements IDisposable
|
@implements IAsyncDisposable
|
||||||
@using System.Globalization
|
@using System.Globalization
|
||||||
@inject GestionaDenunciasAN.Models.UserState userState
|
@inject GestionaDenunciasAN.Models.UserState userState
|
||||||
@inject IHttpContextAccessor HttpContextAccessor
|
@inject IHttpContextAccessor HttpContextAccessor
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject UiBusyService Busy
|
@inject UiBusyService Busy
|
||||||
@inject ApiDenunciasClient ApiDenuncias
|
@inject ApiDenunciasClient ApiDenuncias
|
||||||
|
@inject ILogger<MainLayout> Logger
|
||||||
|
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar">
|
||||||
@@ -59,12 +60,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@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 CurrentPageTitle { get; set; } = "Portal de gestion";
|
||||||
private string CurrentPageDescription { get; set; } =
|
private string CurrentPageDescription { get; set; } =
|
||||||
"Entrada, revision y tramitacion coordinada de denuncias y actualizaciones.";
|
"Entrada, revision y tramitacion coordinada de denuncias y actualizaciones.";
|
||||||
private string EncryptionKeyText { get; set; } = "Clave diaria: cargando...";
|
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 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 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 =>
|
private string DisplayUsername =>
|
||||||
string.IsNullOrWhiteSpace(userState?.NombreUsu)
|
string.IsNullOrWhiteSpace(userState?.NombreUsu)
|
||||||
@@ -87,9 +96,58 @@
|
|||||||
RefreshLayoutState();
|
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;
|
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)
|
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)
|
private static string ToSpanishDateText(string? value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ public sealed class ApiDenunciasClient
|
|||||||
public Task<ApiGlobalLeaksSessionDto?> GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default)
|
public Task<ApiGlobalLeaksSessionDto?> GetGlobalLeaksSessionAsync(CancellationToken cancellationToken = default)
|
||||||
=> SendAsync<ApiGlobalLeaksSessionDto?>(HttpMethod.Get, "api/inbox/session", body: null, authorize: true, cancellationToken, allowNull: true);
|
=> 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)
|
public Task<ApiLoginPrepareResponse> PrepareGlobalLeaksSessionRenewalAsync(CancellationToken cancellationToken = default)
|
||||||
=> SendAsync<ApiLoginPrepareResponse>(
|
=> SendAsync<ApiLoginPrepareResponse>(
|
||||||
HttpMethod.Post,
|
HttpMethod.Post,
|
||||||
|
|||||||
@@ -68,3 +68,62 @@ window.appSetBodyScrollLock = function (locked) {
|
|||||||
document.documentElement.classList.toggle("app-scroll-locked", Boolean(locked));
|
document.documentElement.classList.toggle("app-scroll-locked", Boolean(locked));
|
||||||
document.body.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
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ namespace RegistroPersonalAN.Services
|
|||||||
var client = _httpClientFactory.CreateClient("DefaultClient");
|
var client = _httpClientFactory.CreateClient("DefaultClient");
|
||||||
using var response = await client.PostAsJsonAsync("Auth/login-cert-proxy", new CertificateProxyLoginRequest
|
using var response = await client.PostAsJsonAsync("Auth/login-cert-proxy", new CertificateProxyLoginRequest
|
||||||
{
|
{
|
||||||
Dni = dni
|
Dni = dni,
|
||||||
|
Origen = "Registro"
|
||||||
});
|
});
|
||||||
|
|
||||||
var responseContent = await response.Content.ReadAsStringAsync();
|
var responseContent = await response.Content.ReadAsStringAsync();
|
||||||
@@ -96,6 +97,7 @@ namespace RegistroPersonalAN.Services
|
|||||||
public sealed class CertificateProxyLoginRequest
|
public sealed class CertificateProxyLoginRequest
|
||||||
{
|
{
|
||||||
public string Dni { get; set; } = string.Empty;
|
public string Dni { get; set; } = string.Empty;
|
||||||
|
public string Origen { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class CertificateProxyLoginResponse
|
public sealed class CertificateProxyLoginResponse
|
||||||
|
|||||||
Reference in New Issue
Block a user