diff --git a/Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs b/Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs index fa55d26..32a9f69 100644 --- a/Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs +++ b/Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs @@ -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() { diff --git a/Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs b/Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs index a24502d..33f2de9 100644 --- a/Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs +++ b/Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs @@ -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 Data); + [property: JsonPropertyName("data")] IReadOnlyDictionary Data, + [property: JsonPropertyName("links")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + JsonElement? Links = null); public sealed record OpeVersionResponse( [property: JsonPropertyName("version")] string Version); diff --git a/Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs b/Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs index 59866ae..4e83f98 100644 --- a/Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs +++ b/Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs @@ -10,6 +10,16 @@ namespace ApiOPE.Security; public sealed class OpeAuthenticationMiddleware { + private const int MaxLoggedBodyLength = 64 * 1024; + + private static readonly HashSet 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 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 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, diff --git a/Antifraude.Net/ApiOPE/Services/OpeRequestFileLogger.cs b/Antifraude.Net/ApiOPE/Services/OpeRequestFileLogger.cs index a0a36fb..6e54465 100644 --- a/Antifraude.Net/ApiOPE/Services/OpeRequestFileLogger.cs +++ b/Antifraude.Net/ApiOPE/Services/OpeRequestFileLogger.cs @@ -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)