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);
}
[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]
public async Task RequestReader_ReturnsFieldErrorsForUnexpectedInput()
{

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ApiOPE.Contracts;
@@ -7,7 +8,10 @@ public sealed record OpeFieldValue(
[property: JsonPropertyName("value")] string Value);
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(
[property: JsonPropertyName("version")] string Version);

View File

@@ -10,6 +10,16 @@ namespace ApiOPE.Security;
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 OrganizationIdHeaderName = "X-Organization-ID";
public const string OrganizationDir3HeaderName = "X-Organization-DIR3";
@@ -39,16 +49,30 @@ public sealed class OpeAuthenticationMiddleware
return;
}
var requestHeaders = FormatHeaders(context.Request.Headers);
var requestBody = await ReadRequestBodyAsync(context.Request, context.RequestAborted);
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;
}
var validation = tokenValidator.Validate(token, authentication.Resource, authentication.VersionOptional);
if (!validation.IsValid || validation.RequestContext is null)
{
await RejectAsync(context, validation.FailureReason, requestFileLogger, token);
await RejectAsync(
context,
validation.FailureReason,
requestFileLogger,
requestHeaders,
requestBody,
token);
return;
}
@@ -69,12 +93,21 @@ public sealed class OpeAuthenticationMiddleware
return Task.CompletedTask;
});
var originalResponseBody = context.Response.Body;
await using var capturedResponseBody = new MemoryStream();
context.Response.Body = capturedResponseBody;
try
{
await _next(context);
}
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(
DateTimeOffset.UtcNow,
context.Request.Method,
@@ -91,6 +124,10 @@ public sealed class OpeAuthenticationMiddleware
ReadHeader(context.Request.Headers, "Content-Type"),
ReadHeader(context.Request.Headers, "Accept"),
ReadHeader(context.Request.Headers, "User-Agent"),
requestHeaders,
requestBody,
FormatHeaders(context.Response.Headers),
responseBody,
null));
}
}
@@ -99,6 +136,8 @@ public sealed class OpeAuthenticationMiddleware
HttpContext context,
string reason,
OpeRequestFileLogger requestFileLogger,
string requestHeaders,
string requestBody,
string? token = null,
OpeRequestContext? requestContext = null)
{
@@ -108,6 +147,23 @@ public sealed class OpeAuthenticationMiddleware
context.Request.Path,
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(
DateTimeOffset.UtcNow,
context.Request.Method,
@@ -124,15 +180,11 @@ public sealed class OpeAuthenticationMiddleware
ReadHeader(context.Request.Headers, "Content-Type"),
ReadHeader(context.Request.Headers, "Accept"),
ReadHeader(context.Request.Headers, "User-Agent"),
requestHeaders,
requestBody,
FormatHeaders(context.Response.Headers),
responseBody,
reason));
await OpeResponseWriter.WriteErrorAsync(
context.Response,
StatusCodes.Status401Unauthorized,
"Unauthorized",
OpeErrorCauses.WrongSignature,
null,
context.RequestAborted);
}
private static string Fingerprint(string value)
@@ -143,6 +195,49 @@ public sealed class OpeAuthenticationMiddleware
? string.Join(",", values.ToArray()).Trim()
: 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(
IHeaderDictionary headers,
string name,

View File

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