Cambios en la OPE para aceptar peticiones de gestiona
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user