Cambios en la OPE para aceptar peticiones de gestiona

This commit is contained in:
2026-09-10 13:18:10 +02:00
parent 5c3c12c02c
commit 14fffb1501
4 changed files with 151 additions and 30 deletions

View File

@@ -55,6 +55,23 @@ public sealed class OpeProtocolTests
Assert.Equal("53/2026", result.Request!.Data["FIELD_0"].Value); Assert.Equal("53/2026", result.Request!.Data["FIELD_0"].Value);
} }
[Fact]
public async Task RequestReader_AcceptsGestionaLinksNullProperty()
{
var context = CreateHttpContext(
OpeMediaTypes.GenericOperationRequest,
"""
{"data":{"FIELD_0":{"type":"STRING","value":"68/2026"}},"links":null}
""");
var result = await GenericOperationRequestReader.ReadAsync(
context.Request,
CancellationToken.None);
Assert.True(result.IsValid);
Assert.Equal("68/2026", result.Request!.Data["FIELD_0"].Value);
}
[Fact] [Fact]
public async Task RequestReader_ReturnsFieldErrorsForUnexpectedInput() public async Task RequestReader_ReturnsFieldErrorsForUnexpectedInput()
{ {

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace ApiOPE.Contracts; namespace ApiOPE.Contracts;
@@ -7,7 +8,10 @@ public sealed record OpeFieldValue(
[property: JsonPropertyName("value")] string Value); [property: JsonPropertyName("value")] string Value);
public sealed record OpeDataEnvelope( public sealed record OpeDataEnvelope(
[property: JsonPropertyName("data")] IReadOnlyDictionary<string, OpeFieldValue> Data); [property: JsonPropertyName("data")] IReadOnlyDictionary<string, OpeFieldValue> Data,
[property: JsonPropertyName("links")]
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
JsonElement? Links = null);
public sealed record OpeVersionResponse( public sealed record OpeVersionResponse(
[property: JsonPropertyName("version")] string Version); [property: JsonPropertyName("version")] string Version);

View File

@@ -10,6 +10,16 @@ namespace ApiOPE.Security;
public sealed class OpeAuthenticationMiddleware public sealed class OpeAuthenticationMiddleware
{ {
private const int MaxLoggedBodyLength = 64 * 1024;
private static readonly HashSet<string> SensitiveHeaders = new(StringComparer.OrdinalIgnoreCase)
{
TokenHeaderName,
"Authorization",
"Cookie",
"Set-Cookie"
};
public const string TokenHeaderName = "X-Rest-Basic-Token"; public const string TokenHeaderName = "X-Rest-Basic-Token";
public const string OrganizationIdHeaderName = "X-Organization-ID"; public const string OrganizationIdHeaderName = "X-Organization-ID";
public const string OrganizationDir3HeaderName = "X-Organization-DIR3"; public const string OrganizationDir3HeaderName = "X-Organization-DIR3";
@@ -39,16 +49,30 @@ public sealed class OpeAuthenticationMiddleware
return; return;
} }
var requestHeaders = FormatHeaders(context.Request.Headers);
var requestBody = await ReadRequestBodyAsync(context.Request, context.RequestAborted);
if (!TryGetSingleHeader(context.Request.Headers, TokenHeaderName, out var token)) if (!TryGetSingleHeader(context.Request.Headers, TokenHeaderName, out var token))
{ {
await RejectAsync(context, "Falta la cabecera de autenticacion OPE.", requestFileLogger); await RejectAsync(
context,
"Falta la cabecera de autenticacion OPE.",
requestFileLogger,
requestHeaders,
requestBody);
return; return;
} }
var validation = tokenValidator.Validate(token, authentication.Resource, authentication.VersionOptional); var validation = tokenValidator.Validate(token, authentication.Resource, authentication.VersionOptional);
if (!validation.IsValid || validation.RequestContext is null) if (!validation.IsValid || validation.RequestContext is null)
{ {
await RejectAsync(context, validation.FailureReason, requestFileLogger, token); await RejectAsync(
context,
validation.FailureReason,
requestFileLogger,
requestHeaders,
requestBody,
token);
return; return;
} }
@@ -69,12 +93,21 @@ public sealed class OpeAuthenticationMiddleware
return Task.CompletedTask; return Task.CompletedTask;
}); });
var originalResponseBody = context.Response.Body;
await using var capturedResponseBody = new MemoryStream();
context.Response.Body = capturedResponseBody;
try try
{ {
await _next(context); await _next(context);
} }
finally finally
{ {
var responseBody = await ReadStreamAsync(capturedResponseBody, context.RequestAborted);
capturedResponseBody.Position = 0;
await capturedResponseBody.CopyToAsync(originalResponseBody, context.RequestAborted);
context.Response.Body = originalResponseBody;
await requestFileLogger.WriteAsync(new OpeRequestLogEntry( await requestFileLogger.WriteAsync(new OpeRequestLogEntry(
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
context.Request.Method, context.Request.Method,
@@ -91,6 +124,10 @@ public sealed class OpeAuthenticationMiddleware
ReadHeader(context.Request.Headers, "Content-Type"), ReadHeader(context.Request.Headers, "Content-Type"),
ReadHeader(context.Request.Headers, "Accept"), ReadHeader(context.Request.Headers, "Accept"),
ReadHeader(context.Request.Headers, "User-Agent"), ReadHeader(context.Request.Headers, "User-Agent"),
requestHeaders,
requestBody,
FormatHeaders(context.Response.Headers),
responseBody,
null)); null));
} }
} }
@@ -99,6 +136,8 @@ public sealed class OpeAuthenticationMiddleware
HttpContext context, HttpContext context,
string reason, string reason,
OpeRequestFileLogger requestFileLogger, OpeRequestFileLogger requestFileLogger,
string requestHeaders,
string requestBody,
string? token = null, string? token = null,
OpeRequestContext? requestContext = null) OpeRequestContext? requestContext = null)
{ {
@@ -108,6 +147,23 @@ public sealed class OpeAuthenticationMiddleware
context.Request.Path, context.Request.Path,
reason); reason);
var originalResponseBody = context.Response.Body;
await using var capturedResponseBody = new MemoryStream();
context.Response.Body = capturedResponseBody;
await OpeResponseWriter.WriteErrorAsync(
context.Response,
StatusCodes.Status401Unauthorized,
"Unauthorized",
OpeErrorCauses.WrongSignature,
null,
context.RequestAborted);
var responseBody = await ReadStreamAsync(capturedResponseBody, context.RequestAborted);
capturedResponseBody.Position = 0;
await capturedResponseBody.CopyToAsync(originalResponseBody, context.RequestAborted);
context.Response.Body = originalResponseBody;
await requestFileLogger.WriteAsync(new OpeRequestLogEntry( await requestFileLogger.WriteAsync(new OpeRequestLogEntry(
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
context.Request.Method, context.Request.Method,
@@ -124,15 +180,11 @@ public sealed class OpeAuthenticationMiddleware
ReadHeader(context.Request.Headers, "Content-Type"), ReadHeader(context.Request.Headers, "Content-Type"),
ReadHeader(context.Request.Headers, "Accept"), ReadHeader(context.Request.Headers, "Accept"),
ReadHeader(context.Request.Headers, "User-Agent"), ReadHeader(context.Request.Headers, "User-Agent"),
requestHeaders,
requestBody,
FormatHeaders(context.Response.Headers),
responseBody,
reason)); reason));
await OpeResponseWriter.WriteErrorAsync(
context.Response,
StatusCodes.Status401Unauthorized,
"Unauthorized",
OpeErrorCauses.WrongSignature,
null,
context.RequestAborted);
} }
private static string Fingerprint(string value) private static string Fingerprint(string value)
@@ -143,6 +195,49 @@ public sealed class OpeAuthenticationMiddleware
? string.Join(",", values.ToArray()).Trim() ? string.Join(",", values.ToArray()).Trim()
: null; : null;
private static string FormatHeaders(IHeaderDictionary headers)
=> string.Join(
"; ",
headers
.OrderBy(header => header.Key, StringComparer.OrdinalIgnoreCase)
.Select(header =>
$"{header.Key}={(SensitiveHeaders.Contains(header.Key) ? "[OCULTO]" : string.Join(",", header.Value.ToArray()))}"));
private static async Task<string> ReadRequestBodyAsync(
HttpRequest request,
CancellationToken cancellationToken)
{
request.EnableBuffering();
request.Body.Position = 0;
using var reader = new StreamReader(
request.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: true,
leaveOpen: true);
var body = await reader.ReadToEndAsync(cancellationToken);
request.Body.Position = 0;
return Truncate(body);
}
private static async Task<string> ReadStreamAsync(
Stream stream,
CancellationToken cancellationToken)
{
stream.Position = 0;
using var reader = new StreamReader(
stream,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: true,
leaveOpen: true);
return Truncate(await reader.ReadToEndAsync(cancellationToken));
}
private static string Truncate(string value)
=> value.Length <= MaxLoggedBodyLength
? value
: value[..MaxLoggedBodyLength] + " [TRUNCADO]";
private static bool TryGetSingleHeader( private static bool TryGetSingleHeader(
IHeaderDictionary headers, IHeaderDictionary headers,
string name, string name,

View File

@@ -28,7 +28,7 @@ public sealed class OpeRequestFileLogger
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
} }
var line = entry.ToDisplayLine() + Environment.NewLine; var line = entry.ToDisplayBlock() + Environment.NewLine;
await _writeLock.WaitAsync(cancellationToken); await _writeLock.WaitAsync(cancellationToken);
try try
{ {
@@ -66,27 +66,32 @@ public sealed record OpeRequestLogEntry(
string? ContentType, string? ContentType,
string? Accept, string? Accept,
string? UserAgent, string? UserAgent,
string? RequestHeaders,
string? RequestBody,
string? ResponseHeaders,
string? ResponseBody,
string? FailureReason) string? FailureReason)
{ {
public string ToDisplayLine() public string ToDisplayBlock()
=> string.Join( => string.Join(
" | ", Environment.NewLine,
TimestampUtc.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss zzz"), new string('=', 100),
Method, $"FECHA: {TimestampUtc.ToLocalTime():dd/MM/yyyy HH:mm:ss zzz}",
Path, $"PETICION: {Method} {Path}",
$"HTTP {StatusCode}", $"RESULTADO: HTTP {StatusCode} ({Result})",
Result, $"IP: {Clean(RemoteIp)}",
$"ip={Clean(RemoteIp)}", $"TRANSACCION: {Clean(TransactionId)}",
$"orgId={Clean(OrganizationId)}", $"REQUEST ID: {Clean(RequestId)}",
$"dir3={Clean(OrganizationDir3)}", $"CLIENTE (HUELLA): {Clean(ClientTokenFingerprint)}",
$"cif={Clean(OrganizationCif)}", $"ORGANIZACION: id={Clean(OrganizationId)} | dir3={Clean(OrganizationDir3)} | cif={Clean(OrganizationCif)}",
$"contentType={Clean(ContentType)}", $"CONTENT-TYPE: {Clean(ContentType)}",
$"accept={Clean(Accept)}", $"ACCEPT: {Clean(Accept)}",
$"userAgent={Clean(UserAgent)}", $"USER-AGENT: {Clean(UserAgent)}",
$"transaccion={TransactionId ?? "-"}", $"MOTIVO: {Clean(FailureReason)}",
$"peticion={RequestId ?? "-"}", $"CABECERAS PETICION: {Clean(RequestHeaders)}",
$"cliente={ClientTokenFingerprint ?? "-"}", $"CUERPO PETICION: {Clean(RequestBody)}",
$"motivo={Clean(FailureReason)}"); $"CABECERAS RESPUESTA: {Clean(ResponseHeaders)}",
$"CUERPO RESPUESTA: {Clean(ResponseBody)}");
private static string Clean(string? value) private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value) => string.IsNullOrWhiteSpace(value)