- Añade el endpoint gestiona-fields por número de expediente de Gestiona.

- Mantiene disponible la consulta existente por número de denuncia.
- Resuelve la asociación mediante el histórico de subidas para conservar el cifrado de complaints.
- Elimina el reintento de llamadas a GlobalLeaks sin DPoP.
- Evita solicitar renovación 2FA ante errores de permisos o validación DPoP.
- Limita la renovación a señales reales de sesión inválida o expirada.
- Añade una traza específica para respuestas 412 de GlobalLeaks.
- Mejora los mensajes de error diferenciando permisos, autenticación y DPoP.
This commit is contained in:
2026-07-13 15:01:38 +02:00
parent 10214805e3
commit 781fecbf42
6 changed files with 115 additions and 31 deletions

View File

@@ -82,13 +82,42 @@ public sealed class DenunciasController : ControllerBase
return NotFound(new ApiError("No se ha encontrado la denuncia solicitada."));
}
return BuildGestionaFieldsResponse(denuncia);
}
[AllowAnonymous]
[HttpGet("{numeroExpediente:int}/{anioExpediente:int}/gestiona-fields")]
public async Task<ActionResult<GestionaExternalFieldsResponse>> GetGestionaFieldsByExpediente(
int numeroExpediente,
int anioExpediente,
CancellationToken cancellationToken)
{
if (numeroExpediente <= 0 || anioExpediente <= 0)
{
return BadRequest(new ApiError("Debes indicar un numero de expediente de Gestiona valido."));
}
var expedienteGestiona = $"{numeroExpediente.ToString(CultureInfo.InvariantCulture)}/{anioExpediente.ToString(CultureInfo.InvariantCulture)}";
var denuncia = await _filteredDenunciaStore.GetDenunciaByGestionaFileCodeAsync(
expedienteGestiona,
cancellationToken);
if (denuncia is null)
{
return NotFound(new ApiError("No se ha encontrado una denuncia asociada al expediente de Gestiona solicitado."));
}
return BuildGestionaFieldsResponse(denuncia);
}
private ActionResult<GestionaExternalFieldsResponse> BuildGestionaFieldsResponse(DenunciasGestiona denuncia)
{
var response = ToGestionaComplaintFields(denuncia);
var missingFields = GetMissingGestionaFields(response);
if (missingFields.Count > 0)
{
_logger.LogInformation(
"Campos Gestiona denuncia {DenunciaId}: campos vacios={MissingFields}; rawReportLength={RawReportLength}; formFieldsCount={FormFieldsCount}.",
denunciaId,
denuncia.Id_Denuncia,
string.Join(", ", missingFields),
denuncia.TextoOriginalReport?.Length ?? 0,
denuncia.GetCamposFormulario().Count);

View File

@@ -386,16 +386,6 @@ public sealed class InboxController : ControllerBase
ex.StatusCode,
ex.Message);
if (operation.Contains("bandeja", StringComparison.OrdinalIgnoreCase))
{
return new ObjectResult(new ApiError(
"GlobalLeaks ha rechazado la sesion al cargar la bandeja. Renueva el 2FA y vuelve a intentarlo.",
SessionExpired: true))
{
StatusCode = StatusCodes.Status401Unauthorized
};
}
if (IsSessionAuthorizationProblem(ex.Message))
{
return new ObjectResult(new ApiError(
@@ -406,8 +396,30 @@ public sealed class InboxController : ControllerBase
};
}
if (IsDpopValidationProblem(ex.Message))
{
return new ObjectResult(new ApiError(
"No tienes permiso en GlobalLeaks para acceder a esa denuncia. Puede ser una denuncia anterior a la creacion de tu usuario; consulta con el administrador del buzon."))
"GlobalLeaks no ha podido validar la proteccion de la sesion (DPoP). La sesion no se ha marcado como caducada. Vuelve a intentarlo y, si se repite, avisa al equipo tecnico."))
{
StatusCode = StatusCodes.Status502BadGateway
};
}
if (IsMissingAuthenticationHeadersProblem(ex.Message))
{
return new ObjectResult(new ApiError(
"No se ha podido completar la autenticacion de la llamada a GlobalLeaks. La sesion no se ha marcado como caducada. Vuelve a intentarlo y, si se repite, avisa al equipo tecnico."))
{
StatusCode = StatusCodes.Status502BadGateway
};
}
var permissionMessage = operation.Contains("bandeja", StringComparison.OrdinalIgnoreCase)
? "GlobalLeaks no ha autorizado la carga de la bandeja para este usuario. La sesion sigue activa; comprueba los permisos del usuario en el buzon."
: "No tienes permiso en GlobalLeaks para acceder a esa denuncia. Puede ser una denuncia anterior a la creacion de tu usuario; consulta con el administrador del buzon.";
return new ObjectResult(new ApiError(
permissionMessage))
{
StatusCode = StatusCodes.Status403Forbidden
};
@@ -430,10 +442,19 @@ public sealed class InboxController : ControllerBase
private static bool IsSessionAuthorizationProblem(string? message)
=> !string.IsNullOrWhiteSpace(message) &&
(message.Contains("Invalid DPoP proof", StringComparison.OrdinalIgnoreCase) ||
message.Contains("No token and no session", StringComparison.OrdinalIgnoreCase) ||
(message.Contains("NotAuthenticated", StringComparison.OrdinalIgnoreCase) ||
message.Contains("Invalid token", StringComparison.OrdinalIgnoreCase) ||
message.Contains("Invalid session", StringComparison.OrdinalIgnoreCase) ||
message.Contains("session", StringComparison.OrdinalIgnoreCase) && message.Contains("expired", StringComparison.OrdinalIgnoreCase));
message.Contains("session", StringComparison.OrdinalIgnoreCase) && message.Contains("expired", StringComparison.OrdinalIgnoreCase) ||
message.Contains("token", StringComparison.OrdinalIgnoreCase) && message.Contains("expired", StringComparison.OrdinalIgnoreCase));
private static bool IsDpopValidationProblem(string? message)
=> !string.IsNullOrWhiteSpace(message) &&
message.Contains("Invalid DPoP proof", StringComparison.OrdinalIgnoreCase);
private static bool IsMissingAuthenticationHeadersProblem(string? message)
=> !string.IsNullOrWhiteSpace(message) &&
message.Contains("No token and no session", StringComparison.OrdinalIgnoreCase);
private async Task<GlobalLeaksStoredSession?> RequireActiveSessionAsync(string username, CancellationToken cancellationToken)
{

View File

@@ -198,6 +198,14 @@ public sealed class EncryptedDenunciaStore : IDenunciaStore, IFilteredDenunciaSt
return denuncia is null ? null : await UnprotectComplaintAsync(denuncia, [], cancellationToken);
}
public async Task<DenunciasGestiona?> GetDenunciaByGestionaFileCodeAsync(
string gestionaFileCode,
CancellationToken cancellationToken = default)
{
var denuncia = await _inner.GetDenunciaByGestionaFileCodeAsync(gestionaFileCode, cancellationToken);
return denuncia is null ? null : await UnprotectComplaintAsync(denuncia, [], cancellationToken);
}
public async Task UpsertDenunciaAsync(DenunciasGestiona denuncia, CancellationToken cancellationToken = default)
{
var key = await _envelopeKeyProvider.GetCurrentKeyAsync(cancellationToken);

View File

@@ -667,6 +667,10 @@ public sealed class GlobalLeaksClient
var response = await _httpClient.SendAsync(request, completionOption, cancellationToken);
if ((int)response.StatusCode == 412)
{
_logger.LogWarning(
"GlobalLeaks ha indicado sesion expirada (412) en {Method} {Path}.",
request.Method.Method,
request.RequestUri?.OriginalString);
response.Dispose();
throw new GlobalLeaksSessionExpiredException();
}
@@ -704,23 +708,9 @@ public sealed class GlobalLeaksClient
return await SendGlRequestAsync(requestWithoutDpop, cancellationToken, completionOption);
}
try
{
using var requestWithDpop = CreateAuthenticatedRequest(method, path, sessionId, dpopPrivateKey);
return await SendGlRequestAsync(requestWithDpop, cancellationToken, completionOption);
}
catch (GlobalLeaksValidationException ex) when (ex.StatusCode is StatusCodes.Status401Unauthorized or StatusCodes.Status403Forbidden)
{
_logger.LogWarning(
ex,
"GlobalLeaks ha rechazado {Method} {Path} con DPoP. Se reintentara una vez solo con X-Session para compatibilidad.",
method.Method,
path);
using var fallbackRequest = CreateAuthenticatedRequest(method, path, sessionId, dpopPrivateKey: null);
return await SendGlRequestAsync(fallbackRequest, cancellationToken, completionOption);
}
}
private static HttpRequestMessage CreateRequest(HttpMethod method, string path)
{

View File

@@ -4,6 +4,10 @@ namespace ApiDenuncias.Services;
public interface IFilteredDenunciaStore
{
Task<DenunciasGestiona?> GetDenunciaByGestionaFileCodeAsync(
string gestionaFileCode,
CancellationToken cancellationToken = default);
Task<List<DenunciasGestiona>> GetDenunciasByIdsAsync(
IReadOnlyCollection<int> denunciaIds,
CancellationToken cancellationToken = default);

View File

@@ -551,6 +551,38 @@ public sealed class MySqlDenunciaStore : IDenunciaStore
: null;
}
public async Task<DenunciasGestiona?> GetDenunciaByGestionaFileCodeAsync(
string gestionaFileCode,
CancellationToken cancellationToken = default)
{
await EnsureSchemaReadyAsync(cancellationToken);
var sql = $"""
SELECT
{ComplaintSelectColumns}
FROM complaints
WHERE external_report_id = (
SELECT history.external_report_id
FROM gestiona_upload_history history
WHERE history.gestiona_file_code = @gestionaFileCode
ORDER BY history.uploaded_at_utc DESC, history.id DESC
LIMIT 1
)
OR gestiona_file_code = @gestionaFileCode
ORDER BY COALESCE(gestiona_uploaded_at_utc, report_date_utc) DESC, external_report_id DESC
LIMIT 1;
""";
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@gestionaFileCode", gestionaFileCode.Trim());
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken)
? MapComplaint(reader)
: null;
}
public async Task AddGestionaUploadHistoryAsync(
GestionaUploadHistoryCreateRequest request,
string username,