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();
|
||||
|
||||
_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));
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
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)
|
||||
?? 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<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);
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user