diff --git a/.gitignore b/.gitignore index ed6d1d2..d0b32af 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,9 @@ project.lock.json project.fragment.lock.json artifacts/ +# Local deployment settings containing machine-specific credentials +**/appsettings.*.local.json + # ASP.NET Scaffolding ScaffoldingReadMe.txt diff --git a/Antifraude.Net/Antifraude.Net.sln b/Antifraude.Net/Antifraude.Net.sln index 140d918..86d993e 100644 --- a/Antifraude.Net/Antifraude.Net.sln +++ b/Antifraude.Net/Antifraude.Net.sln @@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiDenuncias", "ApiDenuncia EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GestionaDenuncias.Shared", "GestionaDenuncias.Shared\GestionaDenuncias.Shared.csproj", "{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiOPE", "ApiOPE\ApiOPE.csproj", "{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -111,6 +113,18 @@ Global {94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|x64.Build.0 = Release|Any CPU {94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|x86.ActiveCfg = Release|Any CPU {94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|x86.Build.0 = Release|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x64.ActiveCfg = Debug|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x64.Build.0 = Debug|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x86.ActiveCfg = Debug|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x86.Build.0 = Debug|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|Any CPU.Build.0 = Release|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x64.ActiveCfg = Release|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x64.Build.0 = Release|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x86.ActiveCfg = Release|Any CPU + {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj b/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj index 47e9f0c..d4a7be9 100644 --- a/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj +++ b/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj @@ -20,6 +20,10 @@ + + + + diff --git a/Antifraude.Net/ApiDenuncias/Configuration/OpeBridgeOptions.cs b/Antifraude.Net/ApiDenuncias/Configuration/OpeBridgeOptions.cs new file mode 100644 index 0000000..353da37 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Configuration/OpeBridgeOptions.cs @@ -0,0 +1,10 @@ +namespace ApiDenuncias.Configuration; + +public sealed class OpeBridgeOptions +{ + public const string SectionName = "OpeBridge"; + + public string ApiKey { get; set; } = string.Empty; + + public string ApiKeyHeaderName { get; set; } = "X-ApiOPE-Key"; +} diff --git a/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs b/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs index b62a387..b6dcf22 100644 --- a/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs +++ b/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Text; using System.Text.RegularExpressions; using ApiDenuncias.Services; +using ApiDenuncias.Security; using GestionaDenuncias.Shared.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -86,6 +87,7 @@ public sealed class DenunciasController : ControllerBase } [AllowAnonymous] + [ServiceFilter(typeof(OpeBridgeApiKeyFilter))] [HttpGet("{denunciaId:int}/gestiona-fields")] public async Task> GetGestionaFields( int denunciaId, @@ -106,6 +108,7 @@ public sealed class DenunciasController : ControllerBase } [AllowAnonymous] + [ServiceFilter(typeof(OpeBridgeApiKeyFilter))] [HttpGet("{numeroExpediente:int}/{anioExpediente:int}/gestiona-fields")] public async Task> GetGestionaFieldsByExpediente( int numeroExpediente, diff --git a/Antifraude.Net/ApiDenuncias/Program.cs b/Antifraude.Net/ApiDenuncias/Program.cs index 2f6e588..47959e8 100644 --- a/Antifraude.Net/ApiDenuncias/Program.cs +++ b/Antifraude.Net/ApiDenuncias/Program.cs @@ -1,25 +1,29 @@ using System.Net.Http.Headers; using System.Text; using ApiDenuncias.Configuration; +using ApiDenuncias.Security; using ApiDenuncias.Services; -using ApiDenuncias.Configuration; using GestionaDenuncias.Shared.Models; -using ApiDenuncias.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Diagnostics; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; -using GestionaDenuncias.Shared.Models; var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile( + $"appsettings.{builder.Environment.EnvironmentName}.local.json", + optional: true, + reloadOnChange: false); + builder.Services.Configure(builder.Configuration.GetSection(JwtOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(KeyVaultOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection("Gestiona")); builder.Services.Configure(builder.Configuration.GetSection(GlobalLeaksOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(ComplaintStorageOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(ManualPurgeOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(OpeBridgeOptions.SectionName)); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); @@ -48,6 +52,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient((sp, client) => { diff --git a/Antifraude.Net/ApiDenuncias/Security/OpeBridgeApiKeyFilter.cs b/Antifraude.Net/ApiDenuncias/Security/OpeBridgeApiKeyFilter.cs new file mode 100644 index 0000000..c4fed72 --- /dev/null +++ b/Antifraude.Net/ApiDenuncias/Security/OpeBridgeApiKeyFilter.cs @@ -0,0 +1,58 @@ +using System.Security.Cryptography; +using System.Text; +using ApiDenuncias.Configuration; +using GestionaDenuncias.Shared.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; + +namespace ApiDenuncias.Security; + +public sealed class OpeBridgeApiKeyFilter : IAsyncActionFilter +{ + private readonly OpeBridgeOptions _options; + private readonly ILogger _logger; + + public OpeBridgeApiKeyFilter( + IOptions options, + ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public async Task OnActionExecutionAsync( + ActionExecutingContext context, + ActionExecutionDelegate next) + { + if (string.IsNullOrWhiteSpace(_options.ApiKey) || _options.ApiKey.Length < 32) + { + _logger.LogError("La clave interna de ApiOPE no esta configurada."); + context.Result = new ObjectResult(new ApiError("El acceso interno OPE no esta configurado.")) + { + StatusCode = StatusCodes.Status503ServiceUnavailable + }; + return; + } + + if (!context.HttpContext.Request.Headers.TryGetValue(_options.ApiKeyHeaderName, out var values) || + values.Count != 1 || + !FixedTimeEquals(values[0] ?? string.Empty, _options.ApiKey)) + { + _logger.LogWarning( + "Intento no autorizado de acceso interno OPE en {Path}.", + context.HttpContext.Request.Path); + context.Result = new UnauthorizedObjectResult(new ApiError("No autorizado.")); + return; + } + + await next(); + } + + private static bool FixedTimeEquals(string supplied, string expected) + { + var suppliedHash = SHA256.HashData(Encoding.UTF8.GetBytes(supplied)); + var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(expected)); + return CryptographicOperations.FixedTimeEquals(suppliedHash, expectedHash); + } +} diff --git a/Antifraude.Net/ApiDenuncias/appsettings.Development.json b/Antifraude.Net/ApiDenuncias/appsettings.Development.json index 7e84ef1..afe2aac 100644 --- a/Antifraude.Net/ApiDenuncias/appsettings.Development.json +++ b/Antifraude.Net/ApiDenuncias/appsettings.Development.json @@ -15,5 +15,9 @@ "ConnectionString": "Server=192.168.41.25;Port=13306;Database=gestiondenuncias;Uid=tecnosis;Pwd=tsl4net.Ts87;", "UseKeyVault": false, "AutoCreateSchema": true + }, + "OpeBridge": { + "ApiKey": "development-only-internal-ope-key-change-me", + "ApiKeyHeaderName": "X-ApiOPE-Key" } } diff --git a/Antifraude.Net/ApiDenuncias/appsettings.json b/Antifraude.Net/ApiDenuncias/appsettings.json index 6f24d06..4c2c64b 100644 --- a/Antifraude.Net/ApiDenuncias/appsettings.json +++ b/Antifraude.Net/ApiDenuncias/appsettings.json @@ -71,5 +71,9 @@ "DefaultPort": 3306, "DefaultSslMode": "Required", "AutoCreateSchema": true + }, + "OpeBridge": { + "ApiKey": "", + "ApiKeyHeaderName": "X-ApiOPE-Key" } } diff --git a/Antifraude.Net/ApiOPE.Tests/ApiOPE.Tests.csproj b/Antifraude.Net/ApiOPE.Tests/ApiOPE.Tests.csproj new file mode 100644 index 0000000..486d5eb --- /dev/null +++ b/Antifraude.Net/ApiOPE.Tests/ApiOPE.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Antifraude.Net/ApiOPE.Tests/GlobalUsings.cs b/Antifraude.Net/ApiOPE.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/Antifraude.Net/ApiOPE.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/Antifraude.Net/ApiOPE.Tests/OpeFieldMapperTests.cs b/Antifraude.Net/ApiOPE.Tests/OpeFieldMapperTests.cs new file mode 100644 index 0000000..5b2789a --- /dev/null +++ b/Antifraude.Net/ApiOPE.Tests/OpeFieldMapperTests.cs @@ -0,0 +1,92 @@ +using ApiOPE.Configuration; +using ApiOPE.Contracts; +using ApiOPE.Services; +using Microsoft.Extensions.Options; + +namespace ApiOPE.Tests; + +public sealed class OpeFieldMapperTests +{ + [Fact] + public void Map_UsesConfiguredOrderAndFieldNames() + { + var options = Options.Create(new OpeOptions + { + OutputFields = ["numeroDenunciaCanal", "fechaDenuncia", "resumenDenuncia"] + }); + var mapper = new OpeFieldMapper(options); + var source = new InternalGestionaFieldsResponse( + new Dictionary + { + ["fechaDenuncia"] = new("STRING", "2026-07-10"), + ["numeroDenunciaCanal"] = new("STRING", "116"), + ["resumenDenuncia"] = new("STRING", "Prueba") + }); + + var result = mapper.Map(source); + + Assert.Equal("116", result.Data["FIELD_0"].Value); + Assert.Equal("2026-07-10", result.Data["FIELD_1"].Value); + Assert.Equal("Prueba", result.Data["FIELD_2"].Value); + } + + [Fact] + public void Map_RejectsMissingInternalField() + { + var mapper = new OpeFieldMapper(Options.Create(new OpeOptions + { + OutputFields = ["fechaDenuncia"] + })); + var source = new InternalGestionaFieldsResponse( + new Dictionary()); + + Assert.Throws(() => mapper.Map(source)); + } + + [Theory] + [InlineData("53/2026", "api/denuncias/53/2026/gestiona-fields")] + [InlineData("116", "api/denuncias/116/gestiona-fields")] + public void LookupParser_AcceptsExpedienteAndComplaintId(string value, string expectedPath) + { + var parsed = DenunciaLookupParser.TryParse(value, out var lookup); + + Assert.True(parsed); + Assert.Equal(expectedPath, lookup!.RelativePath); + } + + [Theory] + [InlineData(0)] + [InlineData(11)] + public void OptionsValidator_RejectsInvalidOutputCount(int count) + { + var options = new OpeOptions + { + ClientToken = "client", + SecretKey = "test-secret-key-with-more-than-32-characters", + PublicBaseUrl = "https://ope.example.test", + OrganizationId = "organization", + OrganizationDir3 = "dir3", + OrganizationCif = "cif", + OutputFields = Enumerable.Repeat("fechaDenuncia", count).ToList() + }; + + Assert.False(OpeOptionsValidator.IsValid(options, isDevelopment: false)); + } + + [Fact] + public void OptionsValidator_AcceptsTenUniqueConfiguredFields() + { + var options = new OpeOptions + { + ClientToken = "client", + SecretKey = "test-secret-key-with-more-than-32-characters", + PublicBaseUrl = "https://ope.example.test", + OrganizationId = "organization", + OrganizationDir3 = "dir3", + OrganizationCif = "cif", + OutputFields = OpeFieldMapper.SupportedInternalFields.Take(10).ToList() + }; + + Assert.True(OpeOptionsValidator.IsValid(options, isDevelopment: false)); + } +} diff --git a/Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs b/Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs new file mode 100644 index 0000000..fa55d26 --- /dev/null +++ b/Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs @@ -0,0 +1,83 @@ +using System.Text; +using ApiOPE.Configuration; +using ApiOPE.Contracts; +using ApiOPE.Security; +using ApiOPE.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace ApiOPE.Tests; + +public sealed class OpeProtocolTests +{ + [Fact] + public void ResponseSigner_MatchesGestionaHexThenBase64Algorithm() + { + var signer = new OpeResponseSigner(Options.Create(new OpeOptions + { + SecretKey = "test-secret-key-with-more-than-32-characters" + })); + + var signature = signer.Sign("26cd0da5-c45f-497d-b380-820c03cf3237"); + + Assert.Equal( + "MmE0MjEwM2Y1NTM2NjRkM2FmMzRhNDZlNmQxNjE0NzVmNmE0OTQxNDUxN2NlYmU2ODZjOTZiYzg5YmFjY2FmYw==", + signature); + } + + [Fact] + public void ResponseSigner_PreservesUuidTextCasing() + { + var signer = new OpeResponseSigner(Options.Create(new OpeOptions + { + SecretKey = "test-secret-key-with-more-than-32-characters" + })); + + Assert.NotEqual( + signer.Sign("26cd0da5-c45f-497d-b380-820c03cf3237"), + signer.Sign("26CD0DA5-C45F-497D-B380-820C03CF3237")); + } + + [Fact] + public async Task RequestReader_AcceptsVendorJsonMediaType() + { + var context = CreateHttpContext( + "application/vnd.generic-operation-request+json; charset=utf-8", + """ + {"data":{"FIELD_0":{"type":"STRING","value":"53/2026"}}} + """); + + var result = await GenericOperationRequestReader.ReadAsync( + context.Request, + CancellationToken.None); + + Assert.True(result.IsValid); + Assert.Equal("53/2026", result.Request!.Data["FIELD_0"].Value); + } + + [Fact] + public async Task RequestReader_ReturnsFieldErrorsForUnexpectedInput() + { + var context = CreateHttpContext( + OpeMediaTypes.GenericOperationRequest, + """ + {"data":{"FIELD_1":{"type":"STRING","value":"unexpected"}}} + """); + + var result = await GenericOperationRequestReader.ReadAsync( + context.Request, + CancellationToken.None); + + Assert.False(result.IsValid); + Assert.Equal(OpeFieldErrors.NotExpected, result.Errors["FIELD_1"]); + Assert.Equal(OpeFieldErrors.Expected, result.Errors["FIELD_0"]); + } + + private static DefaultHttpContext CreateHttpContext(string contentType, string body) + { + var context = new DefaultHttpContext(); + context.Request.ContentType = contentType; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); + return context; + } +} diff --git a/Antifraude.Net/ApiOPE.Tests/OpeTokenValidatorTests.cs b/Antifraude.Net/ApiOPE.Tests/OpeTokenValidatorTests.cs new file mode 100644 index 0000000..8ab47a8 --- /dev/null +++ b/Antifraude.Net/ApiOPE.Tests/OpeTokenValidatorTests.cs @@ -0,0 +1,164 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ApiOPE.Configuration; +using ApiOPE.Security; +using Microsoft.Extensions.Options; + +namespace ApiOPE.Tests; + +public sealed class OpeTokenValidatorTests +{ + private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeSeconds(1_800_000_000); + + [Fact] + public void Validate_AcceptsSignedCurrentToken() + { + var options = CreateOptions(); + var validator = CreateValidator(options); + var token = CreateToken(options, "/genericoperations", Now.ToUnixTimeSeconds(), includeVersion: true); + + var result = validator.Validate(token, "/genericoperations", versionOptional: false); + + Assert.True(result.IsValid); + Assert.NotNull(result.RequestContext); + Assert.Equal("/genericoperations", result.RequestContext.Resource); + } + + [Fact] + public void Validate_RejectsManipulatedSignature() + { + var options = CreateOptions(); + var validator = CreateValidator(options); + var token = CreateToken( + options, + "/genericoperations", + Now.ToUnixTimeSeconds(), + includeVersion: true, + signingSecret: "another-secret-key-that-is-long-enough"); + + var result = validator.Validate(token, "/genericoperations", versionOptional: false); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_RejectsExpiredTimestamp() + { + var options = CreateOptions(); + var validator = CreateValidator(options); + var token = CreateToken( + options, + "/genericoperations", + Now.AddMinutes(-7).ToUnixTimeSeconds(), + includeVersion: true); + + var result = validator.Validate(token, "/genericoperations", versionOptional: false); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_RejectsDifferentResource() + { + var options = CreateOptions(); + var validator = CreateValidator(options); + var token = CreateToken(options, "/", Now.ToUnixTimeSeconds(), includeVersion: true); + + var result = validator.Validate(token, "/genericoperations", versionOptional: false); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_AllowsMissingVersionOnlyForVersionEndpoint() + { + var options = CreateOptions(); + var validator = CreateValidator(options); + var token = CreateToken(options, "/versions/current", Now.ToUnixTimeSeconds(), includeVersion: false); + + var versionResult = validator.Validate(token, "/versions/current", versionOptional: true); + var operationResult = validator.Validate(token, "/versions/current", versionOptional: false); + + Assert.True(versionResult.IsValid); + Assert.False(operationResult.IsValid); + } + + [Fact] + public void Validate_PreservesOriginalUuidTextForResponseSignature() + { + var options = CreateOptions(); + var validator = CreateValidator(options); + const string uppercaseUuid = "26CD0DA5-C45F-497D-B380-820C03CF3237"; + var token = CreateToken( + options, + "/genericoperations", + Now.ToUnixTimeSeconds(), + includeVersion: true, + uuid: uppercaseUuid); + + var result = validator.Validate(token, "/genericoperations", versionOptional: false); + + Assert.True(result.IsValid); + Assert.Equal(uppercaseUuid, result.RequestContext!.Uuid); + } + + private static OpeTokenValidator CreateValidator(OpeOptions options) + => new(Options.Create(options), new FixedTimeProvider(Now)); + + private static OpeOptions CreateOptions() + => new() + { + ClientToken = "client-token", + SecretKey = "test-secret-key-with-more-than-32-characters", + Version = "1.0", + TokenMaxAgeSeconds = 300, + ClockSkewSeconds = 60 + }; + + private static string CreateToken( + OpeOptions options, + string resource, + long timestamp, + bool includeVersion, + string? signingSecret = null, + string uuid = "26cd0da5-c45f-497d-b380-820c03cf3237") + { + var header = Base64Url(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" })); + var payloadValues = new Dictionary + { + ["client_token"] = options.ClientToken, + ["resource"] = resource, + ["uuid"] = uuid, + ["transaction_id"] = "37ec011f-4f0b-4a7b-a807-a83e88c73ce9", + ["timestamp"] = timestamp + }; + if (includeVersion) + { + payloadValues["version"] = options.Version; + } + + var payload = Base64Url(JsonSerializer.SerializeToUtf8Bytes(payloadValues)); + var signedContent = Encoding.ASCII.GetBytes($"{header}.{payload}"); + var signature = HMACSHA256.HashData( + Encoding.UTF8.GetBytes(signingSecret ?? options.SecretKey), + signedContent); + + return $"{header}.{payload}.{Base64Url(signature)}"; + } + + private static string Base64Url(byte[] value) + => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private sealed class FixedTimeProvider : TimeProvider + { + private readonly DateTimeOffset _utcNow; + + public FixedTimeProvider(DateTimeOffset utcNow) + { + _utcNow = utcNow; + } + + public override DateTimeOffset GetUtcNow() => _utcNow; + } +} diff --git a/Antifraude.Net/ApiOPE/ApiOPE.csproj b/Antifraude.Net/ApiOPE/ApiOPE.csproj new file mode 100644 index 0000000..157726c --- /dev/null +++ b/Antifraude.Net/ApiOPE/ApiOPE.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/Antifraude.Net/ApiOPE/ApiOPE.http b/Antifraude.Net/ApiOPE/ApiOPE.http new file mode 100644 index 0000000..1182c88 --- /dev/null +++ b/Antifraude.Net/ApiOPE/ApiOPE.http @@ -0,0 +1,39 @@ +@ApiOPE_HostAddress = http://localhost:5090 +@OrganizationId = 00000000-0000-0000-0000-000000000001 +@OrganizationDir3 = DEV000001 +@OrganizationCif = DEV000001 + +# Los JWT deben generarse con el SecretKey del conector y un uuid nuevo. +# El resource debe coincidir exactamente con la ruta de cada peticion. + +GET {{ApiOPE_HostAddress}}/versions/current +X-Rest-Basic-Token: +X-Organization-ID: {{OrganizationId}} +X-Organization-DIR3: {{OrganizationDir3}} +X-Organization-CIF: {{OrganizationCif}} + +### + +GET {{ApiOPE_HostAddress}}/ +X-Rest-Basic-Token: +X-Organization-ID: {{OrganizationId}} +X-Organization-DIR3: {{OrganizationDir3}} +X-Organization-CIF: {{OrganizationCif}} + +### + +POST {{ApiOPE_HostAddress}}/genericoperations +Content-Type: application/vnd.generic-operation-request+json +X-Rest-Basic-Token: +X-Organization-ID: {{OrganizationId}} +X-Organization-DIR3: {{OrganizationDir3}} +X-Organization-CIF: {{OrganizationCif}} + +{ + "data": { + "FIELD_0": { + "type": "STRING", + "value": "53/2026" + } + } +} diff --git a/Antifraude.Net/ApiOPE/Configuration/InternalApiOptions.cs b/Antifraude.Net/ApiOPE/Configuration/InternalApiOptions.cs new file mode 100644 index 0000000..c2c1abe --- /dev/null +++ b/Antifraude.Net/ApiOPE/Configuration/InternalApiOptions.cs @@ -0,0 +1,30 @@ +namespace ApiOPE.Configuration; + +public sealed class InternalApiOptions +{ + public const string SectionName = "InternalApi"; + + public string BaseUrl { get; set; } = "http://localhost:7093"; + + public string ApiKey { get; set; } = string.Empty; + + public string ApiKeyHeaderName { get; set; } = "X-ApiOPE-Key"; + + public int TimeoutSeconds { get; set; } = 20; +} + +public static class InternalApiOptionsValidator +{ + public static bool IsValid(InternalApiOptions options) + { + return Uri.TryCreate(options.BaseUrl, UriKind.Absolute, out var baseUri) && + (baseUri.Scheme == Uri.UriSchemeHttp || baseUri.Scheme == Uri.UriSchemeHttps) && + string.IsNullOrEmpty(baseUri.UserInfo) && + string.IsNullOrEmpty(baseUri.Query) && + string.IsNullOrEmpty(baseUri.Fragment) && + !string.IsNullOrWhiteSpace(options.ApiKey) && + options.ApiKey.Length >= 32 && + string.Equals(options.ApiKeyHeaderName, "X-ApiOPE-Key", StringComparison.OrdinalIgnoreCase) && + options.TimeoutSeconds is >= 1 and <= 60; + } +} diff --git a/Antifraude.Net/ApiOPE/Configuration/OpeOptions.cs b/Antifraude.Net/ApiOPE/Configuration/OpeOptions.cs new file mode 100644 index 0000000..53b14ef --- /dev/null +++ b/Antifraude.Net/ApiOPE/Configuration/OpeOptions.cs @@ -0,0 +1,62 @@ +using ApiOPE.Services; + +namespace ApiOPE.Configuration; + +public sealed class OpeOptions +{ + public const string SectionName = "Ope"; + + public string Version { get; set; } = "1.0"; + + public string ClientToken { get; set; } = string.Empty; + + public string SecretKey { get; set; } = string.Empty; + + public string PublicBaseUrl { get; set; } = string.Empty; + + public int TokenMaxAgeSeconds { get; set; } = 300; + + public int ClockSkewSeconds { get; set; } = 60; + + public string OrganizationId { get; set; } = string.Empty; + + public string OrganizationDir3 { get; set; } = string.Empty; + + public string OrganizationCif { get; set; } = string.Empty; + + public List OutputFields { get; set; } = []; +} + +public static class OpeOptionsValidator +{ + public static bool IsValid(OpeOptions options, bool isDevelopment) + { + if (!string.Equals(options.Version, "1.0", StringComparison.Ordinal) || + string.IsNullOrWhiteSpace(options.ClientToken) || + string.IsNullOrWhiteSpace(options.SecretKey) || + options.SecretKey.Length < 32 || + string.IsNullOrWhiteSpace(options.OrganizationId) || + string.IsNullOrWhiteSpace(options.OrganizationDir3) || + string.IsNullOrWhiteSpace(options.OrganizationCif) || + options.TokenMaxAgeSeconds is < 1 or > 900 || + options.ClockSkewSeconds is < 0 or > 300 || + options.OutputFields is null || + options.OutputFields.Count is < 1 or > 10 || + options.OutputFields.Distinct(StringComparer.Ordinal).Count() != options.OutputFields.Count || + options.OutputFields.Any(field => !OpeFieldMapper.SupportedInternalFields.Contains(field))) + { + return false; + } + + if (!Uri.TryCreate(options.PublicBaseUrl, UriKind.Absolute, out var publicBaseUri) || + (publicBaseUri.Scheme != Uri.UriSchemeHttps && publicBaseUri.Scheme != Uri.UriSchemeHttp) || + !string.IsNullOrEmpty(publicBaseUri.UserInfo) || + !string.IsNullOrEmpty(publicBaseUri.Query) || + !string.IsNullOrEmpty(publicBaseUri.Fragment)) + { + return false; + } + + return isDevelopment || publicBaseUri.Scheme == Uri.UriSchemeHttps; + } +} diff --git a/Antifraude.Net/ApiOPE/Contracts/InternalApiContracts.cs b/Antifraude.Net/ApiOPE/Contracts/InternalApiContracts.cs new file mode 100644 index 0000000..44d0c33 --- /dev/null +++ b/Antifraude.Net/ApiOPE/Contracts/InternalApiContracts.cs @@ -0,0 +1,6 @@ +using System.Text.Json.Serialization; + +namespace ApiOPE.Contracts; + +public sealed record InternalGestionaFieldsResponse( + [property: JsonPropertyName("data")] IReadOnlyDictionary Data); diff --git a/Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs b/Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs new file mode 100644 index 0000000..a24502d --- /dev/null +++ b/Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; + +namespace ApiOPE.Contracts; + +public sealed record OpeFieldValue( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("value")] string Value); + +public sealed record OpeDataEnvelope( + [property: JsonPropertyName("data")] IReadOnlyDictionary Data); + +public sealed record OpeVersionResponse( + [property: JsonPropertyName("version")] string Version); + +public sealed record OpeTypedInfo( + [property: JsonPropertyName("element")] string? Element, + [property: JsonPropertyName("action")] string? Action, + [property: JsonPropertyName("type")] string? Type, + [property: JsonPropertyName("cause")] string Cause); + +public sealed record OpeErrorResponse( + [property: JsonPropertyName("status_code")] int StatusCode, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("typed_info")] OpeTypedInfo TypedInfo, + [property: JsonPropertyName("data")] IReadOnlyDictionary? Data); + +public static class OpeMediaTypes +{ + public const string Version = "application/vnd.version+json"; + public const string Bookmarks = "application/vnd.bookmark-list+json"; + public const string GenericOperationRequest = "application/vnd.generic-operation-request+json"; + public const string GenericOperationResponse = "application/vnd.generic-operation-response+json"; + public const string Error = "application/vnd.generic-operation.error+json"; +} + +public static class OpeErrorCauses +{ + public const string WrongSignature = "WRONG_SIGNATURE"; + public const string FieldErrors = "FIELD_ERRORS"; + public const string ElementNotExists = "ELEMENT_NOT_EXISTS"; + public const string ConnectorError = "CONNECTOR_ERROR"; +} + +public static class OpeFieldErrors +{ + public const string Expected = "FIELD_EXPECTED"; + public const string NotExpected = "FIELD_NOT_EXPECTED"; + public const string UnexpectedFormat = "FIELD_UNEXPECTED_FORMAT"; +} diff --git a/Antifraude.Net/ApiOPE/Controllers/OpeController.cs b/Antifraude.Net/ApiOPE/Controllers/OpeController.cs new file mode 100644 index 0000000..9ef1a46 --- /dev/null +++ b/Antifraude.Net/ApiOPE/Controllers/OpeController.cs @@ -0,0 +1,164 @@ +using System.Text.Json; +using ApiOPE.Configuration; +using ApiOPE.Contracts; +using ApiOPE.Security; +using ApiOPE.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace ApiOPE.Controllers; + +[ApiController] +[Route("")] +public sealed class OpeController : ControllerBase +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly OpeOptions _options; + private readonly InternalDenunciasClient _internalApi; + private readonly OpeFieldMapper _fieldMapper; + private readonly ILogger _logger; + + public OpeController( + IOptions options, + InternalDenunciasClient internalApi, + OpeFieldMapper fieldMapper, + ILogger logger) + { + _options = options.Value; + _internalApi = internalApi; + _fieldMapper = fieldMapper; + _logger = logger; + } + + [HttpGet("versions/current")] + [OpeAuthenticated("/versions/current", versionOptional: true)] + [Produces(OpeMediaTypes.Version)] + public IActionResult GetCurrentVersion() + => Json(new OpeVersionResponse(_options.Version), OpeMediaTypes.Version); + + [HttpGet] + [OpeAuthenticated("/")] + [Produces(OpeMediaTypes.Bookmarks)] + public IActionResult GetBookmarks() + { + var genericOperationUrl = $"{_options.PublicBaseUrl.TrimEnd('/')}/genericoperations"; + return Json( + new Dictionary(StringComparer.Ordinal) + { + ["generic-operation"] = genericOperationUrl + }, + OpeMediaTypes.Bookmarks); + } + + [HttpPost("genericoperations")] + [OpeAuthenticated("/genericoperations")] + [RequestSizeLimit(64 * 1024)] + [Produces(OpeMediaTypes.GenericOperationResponse)] + [ProducesResponseType(typeof(OpeErrorResponse), StatusCodes.Status412PreconditionFailed)] + public async Task ExecuteGenericOperation(CancellationToken cancellationToken) + { + var requestContext = OpeRequestContextStore.Get(HttpContext) + ?? throw new InvalidOperationException("No existe contexto de autenticacion OPE."); + + var parsedRequest = await GenericOperationRequestReader.ReadAsync(Request, cancellationToken); + if (!parsedRequest.IsValid) + { + return Error( + "Existen errores en los campos", + OpeErrorCauses.FieldErrors, + parsedRequest.Errors); + } + + var identifier = parsedRequest.Request!.Data["FIELD_0"].Value; + if (!DenunciaLookupParser.TryParse(identifier, out var lookup) || lookup is null) + { + return Error( + "Existen errores en los campos", + OpeErrorCauses.FieldErrors, + new Dictionary(StringComparer.Ordinal) + { + ["FIELD_0"] = OpeFieldErrors.UnexpectedFormat + }); + } + + try + { + var result = await _internalApi.GetFieldsAsync( + lookup, + requestContext.TransactionId, + cancellationToken); + + if (result.Status == InternalLookupStatus.NotFound || result.Fields is null) + { + return Error( + "No se ha encontrado la denuncia o expediente solicitado", + OpeErrorCauses.ElementNotExists, + null); + } + + return Json(_fieldMapper.Map(result.Fields), OpeMediaTypes.GenericOperationResponse); + } + catch (OperationCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested) + { + _logger.LogWarning( + "Timeout consultando ApiDenuncias para una OPE. TransactionId={TransactionId}", + requestContext.TransactionId); + return Error("Error en el conector", OpeErrorCauses.ConnectorError, null); + } + catch (HttpRequestException exception) + { + _logger.LogWarning( + exception, + "No se ha podido conectar con ApiDenuncias. TransactionId={TransactionId}", + requestContext.TransactionId); + return Error("Error en el conector", OpeErrorCauses.ConnectorError, null); + } + catch (InternalApiException exception) + { + _logger.LogWarning( + exception, + "ApiDenuncias ha devuelto una respuesta no utilizable. TransactionId={TransactionId}", + requestContext.TransactionId); + return Error("Error en el conector", OpeErrorCauses.ConnectorError, null); + } + catch (InvalidDataException exception) + { + _logger.LogError( + exception, + "El contrato entre ApiOPE y ApiDenuncias no coincide. TransactionId={TransactionId}", + requestContext.TransactionId); + return Error("Error en el conector", OpeErrorCauses.ConnectorError, null); + } + } + + private static ContentResult Error( + string message, + string cause, + IReadOnlyDictionary? data) + { + var response = new OpeErrorResponse( + StatusCodes.Status412PreconditionFailed, + message, + new OpeTypedInfo(null, null, null, cause), + data); + + return Json( + response, + OpeMediaTypes.Error, + StatusCodes.Status412PreconditionFailed); + } + + private static ContentResult Json( + T value, + string contentType, + int statusCode = StatusCodes.Status200OK) + { + return new ContentResult + { + Content = JsonSerializer.Serialize(value, JsonOptions), + ContentType = contentType, + StatusCode = statusCode + }; + } +} diff --git a/Antifraude.Net/ApiOPE/Postman/ApiOPE-PRE.postman_environment.json b/Antifraude.Net/ApiOPE/Postman/ApiOPE-PRE.postman_environment.json new file mode 100644 index 0000000..fffa075 --- /dev/null +++ b/Antifraude.Net/ApiOPE/Postman/ApiOPE-PRE.postman_environment.json @@ -0,0 +1,51 @@ +{ + "id": "02213a08-7ce6-49bc-8ca0-0d12965cfdb2", + "name": "ApiOPE PRE", + "values": [ + { + "key": "base_url", + "value": "http://158.158.42.110:7094", + "type": "default", + "enabled": true + }, + { + "key": "client_token", + "value": "REEMPLAZAR_CLIENT_TOKEN_GESTIONA", + "type": "secret", + "enabled": true + }, + { + "key": "secret_key", + "value": "REEMPLAZAR_SECRET_KEY_GESTIONA", + "type": "secret", + "enabled": true + }, + { + "key": "organization_id", + "value": "REEMPLAZAR_ORGANIZATION_ID", + "type": "default", + "enabled": true + }, + { + "key": "organization_dir3", + "value": "REEMPLAZAR_ORGANIZATION_DIR3", + "type": "default", + "enabled": true + }, + { + "key": "organization_cif", + "value": "REEMPLAZAR_ORGANIZATION_CIF", + "type": "default", + "enabled": true + }, + { + "key": "expediente_gestiona", + "value": "REEMPLAZAR_EXPEDIENTE_NO_PURGADO", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-08-07T00:00:00.000Z", + "_postman_exported_using": "Postman/12" +} diff --git a/Antifraude.Net/ApiOPE/Postman/ApiOPE-Simulacion-Gestiona.postman_collection.json b/Antifraude.Net/ApiOPE/Postman/ApiOPE-Simulacion-Gestiona.postman_collection.json new file mode 100644 index 0000000..a84b1f1 --- /dev/null +++ b/Antifraude.Net/ApiOPE/Postman/ApiOPE-Simulacion-Gestiona.postman_collection.json @@ -0,0 +1,360 @@ +{ + "info": { + "_postman_id": "6d0ba12e-f700-4d79-88f7-6fe9c70c8acf", + "name": "ApiOPE - Simulacion Gestiona", + "description": "Simula las tres llamadas consecutivas que realiza Gestiona contra un conector OPE 1.0. Genera automaticamente el JWT HS256 y valida la firma Signature de cada respuesta.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "function utf8Bytes(value) {", + " return Array.from(new TextEncoder().encode(value));", + "}", + "", + "function rightRotate(value, amount) {", + " return (value >>> amount) | (value << (32 - amount));", + "}", + "", + "function sha256Bytes(input) {", + " const constants = [", + " 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,", + " 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,", + " 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,", + " 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,", + " 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,", + " 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,", + " 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,", + " 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2", + " ];", + " const state = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];", + " const bytes = Array.from(input);", + " const bitLength = bytes.length * 8;", + " bytes.push(0x80);", + " while (bytes.length % 64 !== 56) bytes.push(0);", + " const high = Math.floor(bitLength / 0x100000000);", + " const low = bitLength >>> 0;", + " for (let shift = 24; shift >= 0; shift -= 8) bytes.push((high >>> shift) & 0xff);", + " for (let shift = 24; shift >= 0; shift -= 8) bytes.push((low >>> shift) & 0xff);", + "", + " for (let offset = 0; offset < bytes.length; offset += 64) {", + " const words = new Array(64);", + " for (let i = 0; i < 16; i += 1) {", + " const index = offset + (i * 4);", + " words[i] = ((bytes[index] << 24) | (bytes[index + 1] << 16) | (bytes[index + 2] << 8) | bytes[index + 3]) >>> 0;", + " }", + " for (let i = 16; i < 64; i += 1) {", + " const s0 = rightRotate(words[i - 15], 7) ^ rightRotate(words[i - 15], 18) ^ (words[i - 15] >>> 3);", + " const s1 = rightRotate(words[i - 2], 17) ^ rightRotate(words[i - 2], 19) ^ (words[i - 2] >>> 10);", + " words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0;", + " }", + "", + " let [a, b, c, d, e, f, g, h] = state;", + " for (let i = 0; i < 64; i += 1) {", + " const sum1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25);", + " const choice = (e & f) ^ ((~e) & g);", + " const temp1 = (h + sum1 + choice + constants[i] + words[i]) >>> 0;", + " const sum0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22);", + " const majority = (a & b) ^ (a & c) ^ (b & c);", + " const temp2 = (sum0 + majority) >>> 0;", + " h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;", + " }", + "", + " state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0;", + " state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0;", + " state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0;", + " state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0;", + " }", + "", + " const output = [];", + " state.forEach(word => output.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff));", + " return output;", + "}", + "", + "function hmacSha256Bytes(message, secret) {", + " let key = utf8Bytes(secret);", + " if (key.length > 64) key = sha256Bytes(key);", + " while (key.length < 64) key.push(0);", + " const inner = key.map(value => value ^ 0x36).concat(utf8Bytes(message));", + " const outer = key.map(value => value ^ 0x5c).concat(sha256Bytes(inner));", + " return sha256Bytes(outer);", + "}", + "", + "function bytesToBase64(bytes) {", + " let binary = '';", + " bytes.forEach(value => { binary += String.fromCharCode(value); });", + " return btoa(binary);", + "}", + "", + "function toBase64Url(bytes) {", + " return bytesToBase64(bytes).replace(/=+$/, '').replace(/\\+/g, '-').replace(/\\//g, '_');", + "}", + "", + "const requests = {", + " '01 - Consultar version': { resource: '/versions/current', includeVersion: false, resetTransaction: true },", + " '02 - Consultar bookmarks': { resource: '/', includeVersion: true, resetTransaction: false },", + " '03 - Consultar datos de denuncia': { resource: '/genericoperations', includeVersion: true, resetTransaction: false }", + "};", + "", + "const current = requests[pm.info.requestName];", + "if (!current) {", + " throw new Error(`No existe configuracion OPE para la peticion ${pm.info.requestName}`);", + "}", + "", + "const requiredVariables = [", + " 'base_url',", + " 'client_token',", + " 'secret_key',", + " 'organization_id',", + " 'organization_dir3',", + " 'organization_cif'", + "];", + "", + "for (const variableName of requiredVariables) {", + " const value = pm.variables.get(variableName);", + " if (!value || value.startsWith('REEMPLAZAR_')) {", + " throw new Error(`Debes rellenar la variable ${variableName} en el entorno de Postman`);", + " }", + "}", + "", + "if (current.resource === '/genericoperations') {", + " const expediente = pm.variables.get('expediente_gestiona');", + " if (!expediente || expediente.startsWith('REEMPLAZAR_')) {", + " throw new Error('Debes indicar un expediente no purgado en expediente_gestiona');", + " }", + "}", + "", + "if (current.resetTransaction || !pm.collectionVariables.get('ope_transaction_id')) {", + " pm.collectionVariables.set('ope_transaction_id', pm.variables.replaceIn('{{$guid}}'));", + "}", + "", + "const requestUuid = pm.variables.replaceIn('{{$guid}}');", + "const payload = {", + " client_token: pm.variables.get('client_token'),", + " resource: current.resource,", + " uuid: requestUuid,", + " timestamp: Math.floor(Date.now() / 1000),", + " transaction_id: pm.collectionVariables.get('ope_transaction_id')", + "};", + "", + "if (current.includeVersion) {", + " payload.version = pm.variables.get('ope_version') || '1.0';", + "}", + "", + "const headerPart = toBase64Url(utf8Bytes(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));", + "const payloadPart = toBase64Url(utf8Bytes(JSON.stringify(payload)));", + "const signedContent = `${headerPart}.${payloadPart}`;", + "const signaturePart = toBase64Url(hmacSha256Bytes(signedContent, pm.variables.get('secret_key')));", + "const token = `${signedContent}.${signaturePart}`;", + "", + "pm.collectionVariables.set('ope_uuid', requestUuid);", + "pm.collectionVariables.set('ope_jwt', token);" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "function utf8Bytes(value) {", + " return Array.from(new TextEncoder().encode(value));", + "}", + "", + "function rightRotate(value, amount) {", + " return (value >>> amount) | (value << (32 - amount));", + "}", + "", + "function sha256Bytes(input) {", + " const constants = [", + " 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,", + " 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,", + " 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,", + " 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,", + " 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,", + " 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,", + " 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,", + " 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2", + " ];", + " const state = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];", + " const bytes = Array.from(input);", + " const bitLength = bytes.length * 8;", + " bytes.push(0x80);", + " while (bytes.length % 64 !== 56) bytes.push(0);", + " const high = Math.floor(bitLength / 0x100000000);", + " const low = bitLength >>> 0;", + " for (let shift = 24; shift >= 0; shift -= 8) bytes.push((high >>> shift) & 0xff);", + " for (let shift = 24; shift >= 0; shift -= 8) bytes.push((low >>> shift) & 0xff);", + "", + " for (let offset = 0; offset < bytes.length; offset += 64) {", + " const words = new Array(64);", + " for (let i = 0; i < 16; i += 1) {", + " const index = offset + (i * 4);", + " words[i] = ((bytes[index] << 24) | (bytes[index + 1] << 16) | (bytes[index + 2] << 8) | bytes[index + 3]) >>> 0;", + " }", + " for (let i = 16; i < 64; i += 1) {", + " const s0 = rightRotate(words[i - 15], 7) ^ rightRotate(words[i - 15], 18) ^ (words[i - 15] >>> 3);", + " const s1 = rightRotate(words[i - 2], 17) ^ rightRotate(words[i - 2], 19) ^ (words[i - 2] >>> 10);", + " words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0;", + " }", + "", + " let [a, b, c, d, e, f, g, h] = state;", + " for (let i = 0; i < 64; i += 1) {", + " const sum1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25);", + " const choice = (e & f) ^ ((~e) & g);", + " const temp1 = (h + sum1 + choice + constants[i] + words[i]) >>> 0;", + " const sum0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22);", + " const majority = (a & b) ^ (a & c) ^ (b & c);", + " const temp2 = (sum0 + majority) >>> 0;", + " h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;", + " }", + "", + " state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0;", + " state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0;", + " state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0;", + " state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0;", + " }", + "", + " const output = [];", + " state.forEach(word => output.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff));", + " return output;", + "}", + "", + "function hmacSha256Bytes(message, secret) {", + " let key = utf8Bytes(secret);", + " if (key.length > 64) key = sha256Bytes(key);", + " while (key.length < 64) key.push(0);", + " const inner = key.map(value => value ^ 0x36).concat(utf8Bytes(message));", + " const outer = key.map(value => value ^ 0x5c).concat(sha256Bytes(inner));", + " return sha256Bytes(outer);", + "}", + "", + "function bytesToBase64(bytes) {", + " let binary = '';", + " bytes.forEach(value => { binary += String.fromCharCode(value); });", + " return btoa(binary);", + "}", + "", + "function bytesToHex(bytes) {", + " return bytes.map(value => value.toString(16).padStart(2, '0')).join('');", + "}", + "", + "const expectedContentTypes = {", + " '01 - Consultar version': 'application/vnd.version+json',", + " '02 - Consultar bookmarks': 'application/vnd.bookmark-list+json',", + " '03 - Consultar datos de denuncia': 'application/vnd.generic-operation-response+json'", + "};", + "", + "pm.test('HTTP 200', () => {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Content-Type OPE correcto', () => {", + " const actual = (pm.response.headers.get('Content-Type') || '').split(';')[0].trim();", + " pm.expect(actual).to.eql(expectedContentTypes[pm.info.requestName]);", + "});", + "", + "pm.test('Firma Signature valida', () => {", + " const uuid = pm.collectionVariables.get('ope_uuid');", + " const secret = pm.variables.get('secret_key');", + " const lowercaseHex = bytesToHex(hmacSha256Bytes(uuid, secret));", + " const expected = bytesToBase64(utf8Bytes(lowercaseHex));", + " pm.expect(pm.response.headers.get('Signature')).to.eql(expected);", + "});", + "", + "const body = pm.response.json();", + "", + "if (pm.info.requestName === '01 - Consultar version') {", + " pm.test('Version 1.0', () => pm.expect(body.version).to.eql('1.0'));", + "}", + "", + "if (pm.info.requestName === '02 - Consultar bookmarks') {", + " pm.test('Bookmark de operacion generica correcto', () => {", + " const baseUrl = pm.variables.get('base_url').replace(/\\/$/, '');", + " pm.expect(body['generic-operation']).to.eql(`${baseUrl}/genericoperations`);", + " });", + "}", + "", + "if (pm.info.requestName === '03 - Consultar datos de denuncia') {", + " pm.test('Respuesta contiene entre 1 y 10 campos STRING', () => {", + " pm.expect(body).to.have.property('data');", + " const entries = Object.entries(body.data);", + " pm.expect(entries.length).to.be.within(1, 10);", + " entries.forEach(([key, value], index) => {", + " pm.expect(key).to.eql(`FIELD_${index}`);", + " pm.expect(value.type).to.eql('STRING');", + " pm.expect(value.value).to.be.a('string');", + " });", + " });", + "}" + ] + } + } + ], + "item": [ + { + "name": "01 - Consultar version", + "request": { + "method": "GET", + "header": [ + { "key": "X-Rest-Basic-Token", "value": "{{ope_jwt}}", "type": "text" }, + { "key": "X-Organization-ID", "value": "{{organization_id}}", "type": "text" }, + { "key": "X-Organization-DIR3", "value": "{{organization_dir3}}", "type": "text" }, + { "key": "X-Organization-CIF", "value": "{{organization_cif}}", "type": "text" } + ], + "url": "{{base_url}}/versions/current", + "description": "Primera llamada de Gestiona. Obtiene la version del estandar soportada por el conector." + }, + "response": [] + }, + { + "name": "02 - Consultar bookmarks", + "request": { + "method": "GET", + "header": [ + { "key": "X-Rest-Basic-Token", "value": "{{ope_jwt}}", "type": "text" }, + { "key": "X-Organization-ID", "value": "{{organization_id}}", "type": "text" }, + { "key": "X-Organization-DIR3", "value": "{{organization_dir3}}", "type": "text" }, + { "key": "X-Organization-CIF", "value": "{{organization_cif}}", "type": "text" } + ], + "url": "{{base_url}}/", + "description": "Segunda llamada de Gestiona. Recupera la URL navegable de genericoperations." + }, + "response": [] + }, + { + "name": "03 - Consultar datos de denuncia", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/vnd.generic-operation-request+json", "type": "text" }, + { "key": "X-Rest-Basic-Token", "value": "{{ope_jwt}}", "type": "text" }, + { "key": "X-Organization-ID", "value": "{{organization_id}}", "type": "text" }, + { "key": "X-Organization-DIR3", "value": "{{organization_dir3}}", "type": "text" }, + { "key": "X-Organization-CIF", "value": "{{organization_cif}}", "type": "text" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"data\": {\n \"FIELD_0\": {\n \"type\": \"STRING\",\n \"value\": \"{{expediente_gestiona}}\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": "{{base_url}}/genericoperations", + "description": "Tercera llamada de Gestiona. FIELD_0 contiene el numero de expediente, por ejemplo 53/2026." + }, + "response": [] + } + ], + "variable": [ + { "key": "ope_version", "value": "1.0", "type": "string" }, + { "key": "ope_jwt", "value": "", "type": "string" }, + { "key": "ope_uuid", "value": "", "type": "string" }, + { "key": "ope_transaction_id", "value": "", "type": "string" } + ] +} diff --git a/Antifraude.Net/ApiOPE/Postman/INSTRUCCIONES-PRUEBA.txt b/Antifraude.Net/ApiOPE/Postman/INSTRUCCIONES-PRUEBA.txt new file mode 100644 index 0000000..273f03d --- /dev/null +++ b/Antifraude.Net/ApiOPE/Postman/INSTRUCCIONES-PRUEBA.txt @@ -0,0 +1,43 @@ +PRUEBA API OPE DESDE POSTMAN +============================ + +Contenido +--------- +- ApiOPE-Simulacion-Gestiona.postman_collection.json +- ApiOPE-PRE.postman_environment.json + +Importacion +----------- +1. Abrir Postman y pulsar Import. +2. Importar los dos archivos JSON incluidos en este paquete. +3. Seleccionar el entorno "ApiOPE PRE" en la esquina superior derecha. + +Variables que debe rellenar cada probador +----------------------------------------- +- base_url: direccion publicada de ApiOPE. El paquete propone http://158.158.42.110:7094. +- client_token: token de cliente configurado en ApiOPE. +- secret_key: secreto compartido usado para firmar el JWT HS256. +- organization_id: identificador de la organizacion remitente. +- organization_dir3: codigo DIR3 de la organizacion remitente. +- organization_cif: CIF de la organizacion remitente. +- expediente_gestiona: numero de un expediente de Gestiona que exista y cuyos datos diarios sigan disponibles, por ejemplo 53/2026. + +Las credenciales no se incluyen en este paquete. Deben facilitarse por un canal seguro y guardarse solo como valores locales del entorno de Postman. + +Ejecucion +--------- +Ejecutar las peticiones de la coleccion en este orden: + +1. 01 - Consultar version +2. 02 - Consultar bookmarks +3. 03 - Consultar datos de denuncia + +Las tres peticiones comparten automaticamente el identificador de transaccion. La coleccion genera el JWT de autenticacion y valida el codigo HTTP, el Content-Type y la cabecera Signature de cada respuesta. + +Resultado esperado +------------------ +- Las tres respuestas devuelven HTTP 200. +- Los tests de Postman aparecen en verde. +- La tercera respuesta contiene los campos disponibles del expediente solicitado. + +Si la peticion no llega al servidor, comprobar que el equipo tiene conectividad con 158.158.42.110 y acceso al puerto 7094. diff --git a/Antifraude.Net/ApiOPE/Program.cs b/Antifraude.Net/ApiOPE/Program.cs new file mode 100644 index 0000000..92523ee --- /dev/null +++ b/Antifraude.Net/ApiOPE/Program.cs @@ -0,0 +1,72 @@ +using System.Net.Http.Headers; +using ApiOPE.Configuration; +using ApiOPE.Security; +using ApiOPE.Services; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); + +builder.Configuration.AddJsonFile( + $"appsettings.{builder.Environment.EnvironmentName}.local.json", + optional: true, + reloadOnChange: false); + +var allowHttpPublicBaseUrl = builder.Environment.IsDevelopment() || + builder.Environment.IsEnvironment("PreproductionTest"); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddSingleton(TimeProvider.System); + +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(OpeOptions.SectionName)) + .Validate( + options => OpeOptionsValidator.IsValid(options, allowHttpPublicBaseUrl), + "La configuracion Ope no es valida. Revisa client token, secret, URL publica, organizacion y campos de salida.") + .ValidateOnStart(); + +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(InternalApiOptions.SectionName)) + .Validate( + InternalApiOptionsValidator.IsValid, + "La configuracion InternalApi no es valida. Revisa BaseUrl, ApiKey y TimeoutSeconds.") + .ValidateOnStart(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddHttpClient((services, client) => +{ + var options = services.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute); + client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.DefaultRequestHeaders.TryAddWithoutValidation(options.ApiKeyHeaderName, options.ApiKey); +}); + +var app = builder.Build(); + +app.UseExceptionHandler(exceptionApp => exceptionApp.Run(OpeExceptionHandler.HandleAsync)); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +if (builder.Configuration.GetValue("ForceHttpsRedirection", false)) +{ + app.UseHttpsRedirection(); +} + +app.UseRouting(); +app.UseMiddleware(); +app.MapControllers(); + +app.Run(); + +public partial class Program; diff --git a/Antifraude.Net/ApiOPE/Properties/launchSettings.json b/Antifraude.Net/ApiOPE/Properties/launchSettings.json new file mode 100644 index 0000000..98dc0af --- /dev/null +++ b/Antifraude.Net/ApiOPE/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:32166", + "sslPort": 44376 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5090", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7123;http://localhost:5090", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Antifraude.Net/ApiOPE/README.md b/Antifraude.Net/ApiOPE/README.md new file mode 100644 index 0000000..62a9eb2 --- /dev/null +++ b/Antifraude.Net/ApiOPE/README.md @@ -0,0 +1,153 @@ +# ApiOPE + +`ApiOPE` es el conector externo de operaciones genericas de Gestiona. Expone +unicamente el contrato OPE 1.0 y consulta los datos en `ApiDenuncias`, que debe +permanecer en la red interna. + +## Flujo + +1. Gestiona consulta `GET /versions/current`. +2. Gestiona consulta `GET /` y obtiene el bookmark de la operacion generica. +3. Gestiona llama a `POST /genericoperations` con el numero de expediente + (`53/2026`) en `FIELD_0`. Para pruebas tambien se admite el identificador de + denuncia (`116`). +4. `ApiOPE` consulta el endpoint protegido correspondiente de `ApiDenuncias`. +5. `ApiOPE` transforma la respuesta interna al formato `FIELD_0`...`FIELD_9` y + la devuelve a Gestiona. + +Solo el puerto o FQDN de `ApiOPE` debe publicarse. `ApiDenuncias` debe quedar +limitada a la red interna o a localhost y sus endpoints OPE exigen, ademas, la +cabecera compartida `X-ApiOPE-Key`. + +## Contrato externo + +| Metodo | Ruta | Content-Type de respuesta | +| --- | --- | --- | +| GET | `/versions/current` | `application/vnd.version+json` | +| GET | `/` | `application/vnd.bookmark-list+json` | +| POST | `/genericoperations` | `application/vnd.generic-operation-response+json` | + +Las tres peticiones requieren `X-Rest-Basic-Token`, `X-Organization-ID`, +`X-Organization-DIR3` y `X-Organization-CIF`. El JWT se valida con HS256, +incluyendo `client_token`, `resource`, `uuid`, `timestamp`, `version` cuando +corresponde y `transaction_id`. Las respuestas autenticadas incluyen la +cabecera `Signature` calculada con el algoritmo exigido por Gestiona. + +Los errores de datos, elemento inexistente o fallo del conector se devuelven +como HTTP 412 con `application/vnd.generic-operation.error+json`. Una firma o +identidad de conector invalida devuelve HTTP 401 con causa `WRONG_SIGNATURE`. + +## Configuracion de despliegue + +Los secretos no deben escribirse en los ficheros versionados. Configurar estas +claves como variables de entorno del proceso, ajustes de IIS/App Service o +secretos equivalentes: + +```text +Ope__ClientToken +Ope__SecretKey +Ope__PublicBaseUrl +Ope__OrganizationId +Ope__OrganizationDir3 +Ope__OrganizationCif +Ope__TokenMaxAgeSeconds +Ope__ClockSkewSeconds +Ope__OutputFields__0 ... Ope__OutputFields__9 + +InternalApi__BaseUrl +InternalApi__ApiKey +InternalApi__ApiKeyHeaderName=X-ApiOPE-Key +InternalApi__TimeoutSeconds +``` + +En `ApiDenuncias`, configurar la misma clave interna: + +```text +OpeBridge__ApiKey= +OpeBridge__ApiKeyHeaderName=X-ApiOPE-Key +``` + +`Ope__PublicBaseUrl` debe ser la URL HTTPS publica completa del conector, +incluido cualquier path base. El bookmark se construye a partir de ese valor. + +La aplicacion valida la configuracion al arrancar. Si faltan el secreto, los +datos de organizacion, la URL publica o el mapa de salida, no inicia para evitar +publicar un conector incompleto. + +### Prueba temporal de preproduccion + +Los perfiles IIS de `ApiOPE` y `ApiDenuncias` usan temporalmente el entorno +`PreproductionTest` para probar el conector directamente por IP y HTTP. Sus +credenciales se guardan en estos archivos locales: + +```text +ApiOPE/appsettings.PreproductionTest.local.json +ApiDenuncias/appsettings.PreproductionTest.local.json +``` + +Ambos archivos estan excluidos de Git, pero se copian a los paquetes generados +en esta maquina. La clave `InternalApi__ApiKey` de `ApiOPE` debe coincidir con +`OpeBridge__ApiKey` de `ApiDenuncias`. + +Este perfil no debe utilizarse como configuracion definitiva. Al crear el +conector real en Gestiona se deben configurar por un canal seguro su codigo, +su `Secret Key` y los datos reales de organizacion, publicar la URL HTTPS y +volver a usar un entorno de produccion. + +## Campos de salida + +La API interna ofrece estos 12 campos: + +```text +fechaDenuncia +numeroDenunciaCanal +aQuienDenuncia +resumenDenuncia +fechaHechos +lugarHechos +ambitoCompetencias +solicitaProteccion +sexoDenunciante +autorizaRemisionDenuncia +autorizaNotificacionesViaSms +preferenciaNotificacionSeguimientoDenuncia +``` + +OPE 1.0 admite como maximo 10 campos por operacion. Antes de configurar PRE hay +que acordar cuales diez se publican o dividir la consulta en dos operaciones. +El orden de `Ope__OutputFields__N` determina la correspondencia con `FIELD_N`. + +## Publicacion + +1. Publicar `ApiDenuncias` con `OpeBridge__ApiKey` configurada. +2. Publicar `ApiOPE` con los valores anteriores y una clave interna distinta de + `Ope__SecretKey`. +3. Permitir desde `ApiOPE` la conexion interna a `ApiDenuncias` y bloquear el + acceso exterior directo a esta ultima. +4. Publicar exclusivamente `ApiOPE` mediante HTTPS y configurar su URL base en + el conector de Gestiona. +5. Validar consecutivamente version, bookmarks y operacion generica, incluida + la cabecera `Signature` de cada respuesta. + +Los logs identifican fallos mediante `transaction_id`, pero nunca registran el +JWT, el secreto compartido, la clave interna ni el contenido de la denuncia. + +## Simulacion desde Postman + +En `Postman` se incluyen una coleccion y un entorno importables: + +```text +Postman/ApiOPE-Simulacion-Gestiona.postman_collection.json +Postman/ApiOPE-PRE.postman_environment.json +``` + +1. Importar ambos ficheros en Postman y seleccionar el entorno `ApiOPE PRE`. +2. Rellenar sus siete variables con la misma URL, identidad y secreto que tenga + el conector publicado. El expediente debe corresponder a datos del dia que + aun no hayan sido purgados. +3. Ejecutar la coleccion completa con Runner para conservar un mismo + `transaction_id` durante las tres peticiones. + +La coleccion genera un `uuid` y un JWT HS256 nuevos en cada llamada. Tambien +comprueba automaticamente el status, el media type, el cuerpo y la cabecera +`Signature` devuelta por `ApiOPE`. diff --git a/Antifraude.Net/ApiOPE/Security/OpeAuthenticatedAttribute.cs b/Antifraude.Net/ApiOPE/Security/OpeAuthenticatedAttribute.cs new file mode 100644 index 0000000..642abe6 --- /dev/null +++ b/Antifraude.Net/ApiOPE/Security/OpeAuthenticatedAttribute.cs @@ -0,0 +1,15 @@ +namespace ApiOPE.Security; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class OpeAuthenticatedAttribute : Attribute +{ + public OpeAuthenticatedAttribute(string resource, bool versionOptional = false) + { + Resource = resource; + VersionOptional = versionOptional; + } + + public string Resource { get; } + + public bool VersionOptional { get; } +} diff --git a/Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs b/Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs new file mode 100644 index 0000000..3b9502e --- /dev/null +++ b/Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs @@ -0,0 +1,131 @@ +using System.Security.Claims; +using ApiOPE.Configuration; +using ApiOPE.Contracts; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace ApiOPE.Security; + +public sealed class OpeAuthenticationMiddleware +{ + public const string TokenHeaderName = "X-Rest-Basic-Token"; + public const string OrganizationIdHeaderName = "X-Organization-ID"; + public const string OrganizationDir3HeaderName = "X-Organization-DIR3"; + public const string OrganizationCifHeaderName = "X-Organization-CIF"; + + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public OpeAuthenticationMiddleware( + RequestDelegate next, + ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync( + HttpContext context, + OpeTokenValidator tokenValidator, + OpeResponseSigner responseSigner, + IOptions options) + { + var authentication = context.GetEndpoint()?.Metadata.GetMetadata(); + if (authentication is null) + { + await _next(context); + return; + } + + if (!TryGetSingleHeader(context.Request.Headers, TokenHeaderName, out var token)) + { + await RejectAsync(context, "Falta la cabecera de autenticacion OPE."); + return; + } + + var validation = tokenValidator.Validate(token, authentication.Resource, authentication.VersionOptional); + if (!validation.IsValid || validation.RequestContext is null) + { + await RejectAsync(context, validation.FailureReason); + return; + } + + if (!ValidateOrganizationHeaders(context.Request.Headers, options.Value, out var organizationFailure)) + { + await RejectAsync(context, organizationFailure); + return; + } + + OpeRequestContextStore.Set(context, validation.RequestContext); + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("client_token", validation.RequestContext.ClientToken), + new Claim("transaction_id", validation.RequestContext.TransactionId.ToString()), + new Claim("uuid", validation.RequestContext.Uuid.ToString()) + ], "OpeJwt")); + + context.Response.OnStarting(() => + { + context.Response.Headers["Signature"] = responseSigner.Sign(validation.RequestContext.Uuid); + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers.Pragma = "no-cache"; + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + return Task.CompletedTask; + }); + + await _next(context); + } + + private async Task RejectAsync(HttpContext context, string reason) + { + _logger.LogWarning( + "Peticion OPE rechazada en {Method} {Path}. Motivo={Reason}", + context.Request.Method, + context.Request.Path, + reason); + + await OpeResponseWriter.WriteErrorAsync( + context.Response, + StatusCodes.Status401Unauthorized, + "Unauthorized", + OpeErrorCauses.WrongSignature, + null, + context.RequestAborted); + } + + private static bool ValidateOrganizationHeaders( + IHeaderDictionary headers, + OpeOptions options, + out string failureReason) + { + var valid = MatchesHeader(headers, OrganizationIdHeaderName, options.OrganizationId) && + MatchesHeader(headers, OrganizationDir3HeaderName, options.OrganizationDir3) && + MatchesHeader(headers, OrganizationCifHeaderName, options.OrganizationCif); + + failureReason = valid + ? string.Empty + : "Las cabeceras de organizacion no corresponden al conector configurado."; + return valid; + } + + private static bool MatchesHeader(IHeaderDictionary headers, string name, string expected) + { + return TryGetSingleHeader(headers, name, out var value) && + string.Equals(value, expected, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryGetSingleHeader( + IHeaderDictionary headers, + string name, + out string value) + { + value = string.Empty; + if (!headers.TryGetValue(name, out StringValues values) || values.Count != 1) + { + return false; + } + + value = values[0]?.Trim() ?? string.Empty; + return !string.IsNullOrWhiteSpace(value); + } +} diff --git a/Antifraude.Net/ApiOPE/Security/OpeExceptionHandler.cs b/Antifraude.Net/ApiOPE/Security/OpeExceptionHandler.cs new file mode 100644 index 0000000..41ae9ed --- /dev/null +++ b/Antifraude.Net/ApiOPE/Security/OpeExceptionHandler.cs @@ -0,0 +1,31 @@ +using ApiOPE.Contracts; +using Microsoft.AspNetCore.Diagnostics; + +namespace ApiOPE.Security; + +public static class OpeExceptionHandler +{ + public static async Task HandleAsync(HttpContext context) + { + var exception = context.Features.Get()?.Error; + var requestContext = OpeRequestContextStore.Get(context); + var logger = context.RequestServices + .GetRequiredService() + .CreateLogger("ApiOPE.UnhandledException"); + + logger.LogError( + exception, + "Error no controlado en la operacion OPE. Path={Path}; TransactionId={TransactionId}", + context.Request.Path, + requestContext?.TransactionId); + + context.Response.Clear(); + await OpeResponseWriter.WriteErrorAsync( + context.Response, + StatusCodes.Status412PreconditionFailed, + "Error en el conector", + OpeErrorCauses.ConnectorError, + null, + context.RequestAborted); + } +} diff --git a/Antifraude.Net/ApiOPE/Security/OpeRequestContext.cs b/Antifraude.Net/ApiOPE/Security/OpeRequestContext.cs new file mode 100644 index 0000000..4c0e418 --- /dev/null +++ b/Antifraude.Net/ApiOPE/Security/OpeRequestContext.cs @@ -0,0 +1,20 @@ +namespace ApiOPE.Security; + +public sealed record OpeRequestContext( + string ClientToken, + string Resource, + string Uuid, + Guid TransactionId, + long Timestamp, + string? Version); + +public static class OpeRequestContextStore +{ + private static readonly object Key = new(); + + public static void Set(HttpContext context, OpeRequestContext requestContext) + => context.Items[Key] = requestContext; + + public static OpeRequestContext? Get(HttpContext context) + => context.Items.TryGetValue(Key, out var value) ? value as OpeRequestContext : null; +} diff --git a/Antifraude.Net/ApiOPE/Security/OpeResponseSigner.cs b/Antifraude.Net/ApiOPE/Security/OpeResponseSigner.cs new file mode 100644 index 0000000..6a800bc --- /dev/null +++ b/Antifraude.Net/ApiOPE/Security/OpeResponseSigner.cs @@ -0,0 +1,23 @@ +using System.Security.Cryptography; +using System.Text; +using ApiOPE.Configuration; +using Microsoft.Extensions.Options; + +namespace ApiOPE.Security; + +public sealed class OpeResponseSigner +{ + private readonly byte[] _secretKey; + + public OpeResponseSigner(IOptions options) + { + _secretKey = Encoding.UTF8.GetBytes(options.Value.SecretKey); + } + + public string Sign(string uuid) + { + var hash = HMACSHA256.HashData(_secretKey, Encoding.UTF8.GetBytes(uuid)); + var lowercaseHex = Convert.ToHexString(hash).ToLowerInvariant(); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(lowercaseHex)); + } +} diff --git a/Antifraude.Net/ApiOPE/Security/OpeResponseWriter.cs b/Antifraude.Net/ApiOPE/Security/OpeResponseWriter.cs new file mode 100644 index 0000000..e82e370 --- /dev/null +++ b/Antifraude.Net/ApiOPE/Security/OpeResponseWriter.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using ApiOPE.Contracts; + +namespace ApiOPE.Security; + +public static class OpeResponseWriter +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public static async Task WriteErrorAsync( + HttpResponse response, + int statusCode, + string message, + string cause, + IReadOnlyDictionary? data, + CancellationToken cancellationToken = default) + { + response.StatusCode = statusCode; + response.ContentType = OpeMediaTypes.Error; + + var error = new OpeErrorResponse( + statusCode, + message, + new OpeTypedInfo(null, null, null, cause), + data); + + await JsonSerializer.SerializeAsync( + response.Body, + error, + JsonOptions, + cancellationToken); + } +} diff --git a/Antifraude.Net/ApiOPE/Security/OpeTokenValidator.cs b/Antifraude.Net/ApiOPE/Security/OpeTokenValidator.cs new file mode 100644 index 0000000..917a047 --- /dev/null +++ b/Antifraude.Net/ApiOPE/Security/OpeTokenValidator.cs @@ -0,0 +1,238 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ApiOPE.Configuration; +using Microsoft.Extensions.Options; + +namespace ApiOPE.Security; + +public sealed record OpeTokenValidationResult( + bool IsValid, + OpeRequestContext? RequestContext, + string FailureReason) +{ + public static OpeTokenValidationResult Success(OpeRequestContext context) + => new(true, context, string.Empty); + + public static OpeTokenValidationResult Failure(string reason) + => new(false, null, reason); +} + +public sealed class OpeTokenValidator +{ + private const int MaxTokenLength = 16 * 1024; + + private readonly OpeOptions _options; + private readonly TimeProvider _timeProvider; + private readonly byte[] _secretKey; + + public OpeTokenValidator(IOptions options, TimeProvider timeProvider) + { + _options = options.Value; + _timeProvider = timeProvider; + _secretKey = Encoding.UTF8.GetBytes(_options.SecretKey); + } + + public OpeTokenValidationResult Validate( + string token, + string expectedResource, + bool versionOptional) + { + if (string.IsNullOrWhiteSpace(token) || token.Length > MaxTokenLength) + { + return OpeTokenValidationResult.Failure("Token ausente o demasiado largo."); + } + + var segments = token.Split('.'); + if (segments.Length != 3 || segments.Any(string.IsNullOrWhiteSpace)) + { + return OpeTokenValidationResult.Failure("El JWT no contiene tres segmentos."); + } + + byte[] headerBytes; + byte[] payloadBytes; + byte[] receivedSignature; + try + { + headerBytes = DecodeBase64Url(segments[0]); + payloadBytes = DecodeBase64Url(segments[1]); + receivedSignature = DecodeBase64Url(segments[2]); + } + catch (FormatException) + { + return OpeTokenValidationResult.Failure("El JWT no esta codificado en Base64Url valido."); + } + + if (!HasSupportedHeader(headerBytes)) + { + return OpeTokenValidationResult.Failure("El JWT no declara exclusivamente HS256."); + } + + var signedBytes = Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"); + var expectedSignature = HMACSHA256.HashData(_secretKey, signedBytes); + if (receivedSignature.Length != expectedSignature.Length || + !CryptographicOperations.FixedTimeEquals(receivedSignature, expectedSignature)) + { + return OpeTokenValidationResult.Failure("La firma del JWT no es valida."); + } + + try + { + using var payload = JsonDocument.Parse(payloadBytes, new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 8 + }); + + if (payload.RootElement.ValueKind != JsonValueKind.Object || + HasDuplicateProperties(payload.RootElement)) + { + return OpeTokenValidationResult.Failure("El payload del JWT no es un objeto valido."); + } + + var clientToken = GetRequiredString(payload.RootElement, "client_token"); + var resource = GetRequiredString(payload.RootElement, "resource"); + var uuidText = GetRequiredString(payload.RootElement, "uuid"); + var transactionIdText = GetRequiredString(payload.RootElement, "transaction_id"); + var version = GetOptionalString(payload.RootElement, "version"); + var timestamp = GetRequiredInt64(payload.RootElement, "timestamp"); + + if (!FixedTimeEquals(clientToken, _options.ClientToken)) + { + return OpeTokenValidationResult.Failure("El client_token no corresponde al conector."); + } + + if (!string.Equals(resource, expectedResource, StringComparison.Ordinal)) + { + return OpeTokenValidationResult.Failure("El recurso firmado no corresponde al recurso solicitado."); + } + + if ((!versionOptional && !string.Equals(version, _options.Version, StringComparison.Ordinal)) || + (versionOptional && version is not null && !string.Equals(version, _options.Version, StringComparison.Ordinal))) + { + return OpeTokenValidationResult.Failure("La version firmada no es compatible."); + } + + if (!Guid.TryParseExact(uuidText, "D", out _) || + !Guid.TryParseExact(transactionIdText, "D", out var transactionId)) + { + return OpeTokenValidationResult.Failure("uuid o transaction_id no tienen formato UUID."); + } + + var now = _timeProvider.GetUtcNow().ToUnixTimeSeconds(); + if (timestamp > now + _options.ClockSkewSeconds || + timestamp < now - _options.TokenMaxAgeSeconds - _options.ClockSkewSeconds) + { + return OpeTokenValidationResult.Failure("El timestamp del JWT esta fuera de la ventana permitida."); + } + + return OpeTokenValidationResult.Success(new OpeRequestContext( + clientToken, + resource, + uuidText, + transactionId, + timestamp, + version)); + } + catch (JsonException) + { + return OpeTokenValidationResult.Failure("El payload del JWT no contiene JSON valido."); + } + catch (InvalidOperationException exception) + { + return OpeTokenValidationResult.Failure(exception.Message); + } + } + + private static bool HasSupportedHeader(byte[] headerBytes) + { + try + { + using var header = JsonDocument.Parse(headerBytes, new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 4 + }); + + return header.RootElement.ValueKind == JsonValueKind.Object && + !HasDuplicateProperties(header.RootElement) && + string.Equals(GetRequiredString(header.RootElement, "alg"), "HS256", StringComparison.Ordinal); + } + catch (JsonException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static byte[] DecodeBase64Url(string value) + { + var base64 = value.Replace('-', '+').Replace('_', '/'); + base64 = (base64.Length % 4) switch + { + 0 => base64, + 2 => base64 + "==", + 3 => base64 + "=", + _ => throw new FormatException("Longitud Base64Url no valida.") + }; + + return Convert.FromBase64String(base64); + } + + private static bool HasDuplicateProperties(JsonElement element) + { + var names = new HashSet(StringComparer.Ordinal); + return element.EnumerateObject().Any(property => !names.Add(property.Name)); + } + + private static string GetRequiredString(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var property) || + property.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(property.GetString())) + { + throw new InvalidOperationException($"Falta la claim obligatoria {name}."); + } + + return property.GetString()!; + } + + private static string? GetOptionalString(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var property)) + { + return null; + } + + if (property.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(property.GetString())) + { + throw new InvalidOperationException($"La claim {name} tiene un formato incorrecto."); + } + + return property.GetString(); + } + + private static long GetRequiredInt64(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var property) || + property.ValueKind != JsonValueKind.Number || + !property.TryGetInt64(out var value)) + { + throw new InvalidOperationException($"Falta la claim numerica obligatoria {name}."); + } + + return value; + } + + private static bool FixedTimeEquals(string left, string right) + { + var leftHash = SHA256.HashData(Encoding.UTF8.GetBytes(left)); + var rightHash = SHA256.HashData(Encoding.UTF8.GetBytes(right)); + return CryptographicOperations.FixedTimeEquals(leftHash, rightHash); + } +} diff --git a/Antifraude.Net/ApiOPE/Services/DenunciaLookup.cs b/Antifraude.Net/ApiOPE/Services/DenunciaLookup.cs new file mode 100644 index 0000000..f9df417 --- /dev/null +++ b/Antifraude.Net/ApiOPE/Services/DenunciaLookup.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace ApiOPE.Services; + +public sealed record DenunciaLookup(string RelativePath); + +public static partial class DenunciaLookupParser +{ + public static bool TryParse(string value, out DenunciaLookup? lookup) + { + lookup = null; + var trimmed = value.Trim(); + + var expedienteMatch = ExpedientePattern().Match(trimmed); + if (expedienteMatch.Success && + int.TryParse(expedienteMatch.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var number) && + int.TryParse(expedienteMatch.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var year) && + number > 0 && + year is >= 2000 and <= 9999) + { + lookup = new DenunciaLookup($"api/denuncias/{number}/{year}/gestiona-fields"); + return true; + } + + if (int.TryParse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture, out var complaintId) && + complaintId > 0) + { + lookup = new DenunciaLookup($"api/denuncias/{complaintId}/gestiona-fields"); + return true; + } + + return false; + } + + [GeneratedRegex(@"^(\d+)\s*/\s*(\d{4})$", RegexOptions.CultureInvariant)] + private static partial Regex ExpedientePattern(); +} diff --git a/Antifraude.Net/ApiOPE/Services/GenericOperationRequestReader.cs b/Antifraude.Net/ApiOPE/Services/GenericOperationRequestReader.cs new file mode 100644 index 0000000..1cfc9ea --- /dev/null +++ b/Antifraude.Net/ApiOPE/Services/GenericOperationRequestReader.cs @@ -0,0 +1,93 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using ApiOPE.Contracts; + +namespace ApiOPE.Services; + +public sealed record GenericOperationRequestResult( + OpeDataEnvelope? Request, + IReadOnlyDictionary Errors) +{ + public bool IsValid => Request is not null && Errors.Count == 0; +} + +public static class GenericOperationRequestReader +{ + private const string ExpectedField = "FIELD_0"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + MaxDepth = 16 + }; + + public static async Task ReadAsync( + HttpRequest request, + CancellationToken cancellationToken) + { + var errors = new Dictionary(StringComparer.Ordinal); + + if (!MediaTypeHeaderValue.TryParse(request.ContentType, out var contentType) || + !string.Equals( + contentType.MediaType, + OpeMediaTypes.GenericOperationRequest, + StringComparison.OrdinalIgnoreCase)) + { + errors[ExpectedField] = OpeFieldErrors.UnexpectedFormat; + return new GenericOperationRequestResult(null, errors); + } + + OpeDataEnvelope? envelope; + try + { + envelope = await JsonSerializer.DeserializeAsync( + request.Body, + JsonOptions, + cancellationToken); + } + catch (JsonException) + { + errors[ExpectedField] = OpeFieldErrors.UnexpectedFormat; + return new GenericOperationRequestResult(null, errors); + } + + if (envelope?.Data is null) + { + errors[ExpectedField] = OpeFieldErrors.Expected; + return new GenericOperationRequestResult(null, errors); + } + + foreach (var field in envelope.Data) + { + if (!string.Equals(field.Key, ExpectedField, StringComparison.Ordinal)) + { + errors[field.Key] = OpeFieldErrors.NotExpected; + continue; + } + + if (field.Value is null || + !string.Equals(field.Value.Type, "STRING", StringComparison.Ordinal) || + string.IsNullOrWhiteSpace(field.Value.Value)) + { + errors[field.Key] = OpeFieldErrors.UnexpectedFormat; + } + } + + if (!envelope.Data.ContainsKey(ExpectedField)) + { + errors[ExpectedField] = OpeFieldErrors.Expected; + } + + if (envelope.Data.Count > 10) + { + foreach (var field in envelope.Data.Keys.Where(key => key != ExpectedField)) + { + errors[field] = OpeFieldErrors.NotExpected; + } + } + + return new GenericOperationRequestResult(envelope, errors); + } +} diff --git a/Antifraude.Net/ApiOPE/Services/InternalDenunciasClient.cs b/Antifraude.Net/ApiOPE/Services/InternalDenunciasClient.cs new file mode 100644 index 0000000..957abec --- /dev/null +++ b/Antifraude.Net/ApiOPE/Services/InternalDenunciasClient.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Text.Json; +using ApiOPE.Contracts; + +namespace ApiOPE.Services; + +public enum InternalLookupStatus +{ + Success, + NotFound +} + +public sealed record InternalLookupResult( + InternalLookupStatus Status, + InternalGestionaFieldsResponse? Fields); + +public sealed class InternalApiException : Exception +{ + public InternalApiException(string message) + : base(message) + { + } + + public InternalApiException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +public sealed class InternalDenunciasClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = false, + MaxDepth = 16 + }; + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public InternalDenunciasClient( + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task GetFieldsAsync( + DenunciaLookup lookup, + Guid transactionId, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, lookup.RelativePath); + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone) + { + return new InternalLookupResult(InternalLookupStatus.NotFound, null); + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "ApiDenuncias ha rechazado una consulta OPE. Status={StatusCode}; TransactionId={TransactionId}", + (int)response.StatusCode, + transactionId); + throw new InternalApiException("ApiDenuncias no ha podido completar la consulta OPE."); + } + + try + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var fields = await JsonSerializer.DeserializeAsync( + stream, + JsonOptions, + cancellationToken); + + if (fields?.Data is null) + { + throw new InvalidDataException("La respuesta interna no contiene el objeto data."); + } + + return new InternalLookupResult(InternalLookupStatus.Success, fields); + } + catch (JsonException exception) + { + throw new InternalApiException("ApiDenuncias ha devuelto una respuesta no valida.", exception); + } + catch (InvalidDataException exception) + { + throw new InternalApiException("ApiDenuncias ha devuelto una respuesta incompleta.", exception); + } + } +} diff --git a/Antifraude.Net/ApiOPE/Services/OpeFieldMapper.cs b/Antifraude.Net/ApiOPE/Services/OpeFieldMapper.cs new file mode 100644 index 0000000..ced8f62 --- /dev/null +++ b/Antifraude.Net/ApiOPE/Services/OpeFieldMapper.cs @@ -0,0 +1,51 @@ +using ApiOPE.Configuration; +using ApiOPE.Contracts; +using Microsoft.Extensions.Options; + +namespace ApiOPE.Services; + +public sealed class OpeFieldMapper +{ + public static readonly IReadOnlySet SupportedInternalFields = new HashSet( + [ + "fechaDenuncia", + "numeroDenunciaCanal", + "aQuienDenuncia", + "resumenDenuncia", + "fechaHechos", + "lugarHechos", + "ambitoCompetencias", + "solicitaProteccion", + "sexoDenunciante", + "autorizaRemisionDenuncia", + "autorizaNotificacionesViaSms", + "preferenciaNotificacionSeguimientoDenuncia" + ], + StringComparer.Ordinal); + + private readonly string[] _outputFields; + + public OpeFieldMapper(IOptions options) + { + _outputFields = options.Value.OutputFields.ToArray(); + } + + public OpeDataEnvelope Map(InternalGestionaFieldsResponse source) + { + var output = new Dictionary(StringComparer.Ordinal); + + for (var index = 0; index < _outputFields.Length; index++) + { + var internalField = _outputFields[index]; + if (!source.Data.TryGetValue(internalField, out var value)) + { + throw new InvalidDataException( + $"La API interna no ha devuelto el campo configurado '{internalField}'."); + } + + output[$"FIELD_{index}"] = new OpeFieldValue("STRING", value.Value?.Trim() ?? string.Empty); + } + + return new OpeDataEnvelope(output); + } +} diff --git a/Antifraude.Net/ApiOPE/appsettings.Development.json b/Antifraude.Net/ApiOPE/appsettings.Development.json new file mode 100644 index 0000000..df35d6a --- /dev/null +++ b/Antifraude.Net/ApiOPE/appsettings.Development.json @@ -0,0 +1,37 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Ope": { + "Version": "1.0", + "ClientToken": "development-ope-client", + "SecretKey": "development-only-ope-secret-key-change-me", + "PublicBaseUrl": "http://localhost:5090", + "TokenMaxAgeSeconds": 300, + "ClockSkewSeconds": 60, + "OrganizationId": "00000000-0000-0000-0000-000000000001", + "OrganizationDir3": "DEV000001", + "OrganizationCif": "DEV000001", + "OutputFields": [ + "fechaDenuncia", + "numeroDenunciaCanal", + "aQuienDenuncia", + "resumenDenuncia", + "fechaHechos", + "lugarHechos", + "ambitoCompetencias", + "solicitaProteccion", + "sexoDenunciante", + "autorizaRemisionDenuncia" + ] + }, + "InternalApi": { + "BaseUrl": "http://localhost:7093", + "ApiKey": "development-only-internal-ope-key-change-me", + "ApiKeyHeaderName": "X-ApiOPE-Key", + "TimeoutSeconds": 20 + } +} diff --git a/Antifraude.Net/ApiOPE/appsettings.json b/Antifraude.Net/ApiOPE/appsettings.json new file mode 100644 index 0000000..8094dcb --- /dev/null +++ b/Antifraude.Net/ApiOPE/appsettings.json @@ -0,0 +1,28 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ForceHttpsRedirection": false, + "Ope": { + "Version": "1.0", + "ClientToken": "", + "SecretKey": "", + "PublicBaseUrl": "", + "TokenMaxAgeSeconds": 300, + "ClockSkewSeconds": 60, + "OrganizationId": "", + "OrganizationDir3": "", + "OrganizationCif": "", + "OutputFields": [] + }, + "InternalApi": { + "BaseUrl": "http://localhost:7093", + "ApiKey": "", + "ApiKeyHeaderName": "X-ApiOPE-Key", + "TimeoutSeconds": 20 + } +}