Añadido la APIOPE para tesauros externos con la autenticaicon JWT de gestiona
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -63,6 +63,9 @@ project.lock.json
|
|||||||
project.fragment.lock.json
|
project.fragment.lock.json
|
||||||
artifacts/
|
artifacts/
|
||||||
|
|
||||||
|
# Local deployment settings containing machine-specific credentials
|
||||||
|
**/appsettings.*.local.json
|
||||||
|
|
||||||
# ASP.NET Scaffolding
|
# ASP.NET Scaffolding
|
||||||
ScaffoldingReadMe.txt
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiDenuncias", "ApiDenuncia
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GestionaDenuncias.Shared", "GestionaDenuncias.Shared\GestionaDenuncias.Shared.csproj", "{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GestionaDenuncias.Shared", "GestionaDenuncias.Shared\GestionaDenuncias.Shared.csproj", "{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiOPE", "ApiOPE\ApiOPE.csproj", "{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
<ProjectReference Include="..\GestionaDenuncias.Shared\GestionaDenuncias.Shared.csproj" />
|
<ProjectReference Include="..\GestionaDenuncias.Shared\GestionaDenuncias.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="appsettings.*.local.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="Scripts\gestiondenuncias_schema.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||||
<Content Include="Scripts\gestiondenuncias_envelope_encryption.sql" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="Scripts\gestiondenuncias_envelope_encryption.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using ApiDenuncias.Services;
|
using ApiDenuncias.Services;
|
||||||
|
using ApiDenuncias.Security;
|
||||||
using GestionaDenuncias.Shared.Models;
|
using GestionaDenuncias.Shared.Models;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -86,6 +87,7 @@ public sealed class DenunciasController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
[ServiceFilter(typeof(OpeBridgeApiKeyFilter))]
|
||||||
[HttpGet("{denunciaId:int}/gestiona-fields")]
|
[HttpGet("{denunciaId:int}/gestiona-fields")]
|
||||||
public async Task<ActionResult<GestionaExternalFieldsResponse>> GetGestionaFields(
|
public async Task<ActionResult<GestionaExternalFieldsResponse>> GetGestionaFields(
|
||||||
int denunciaId,
|
int denunciaId,
|
||||||
@@ -106,6 +108,7 @@ public sealed class DenunciasController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
[ServiceFilter(typeof(OpeBridgeApiKeyFilter))]
|
||||||
[HttpGet("{numeroExpediente:int}/{anioExpediente:int}/gestiona-fields")]
|
[HttpGet("{numeroExpediente:int}/{anioExpediente:int}/gestiona-fields")]
|
||||||
public async Task<ActionResult<GestionaExternalFieldsResponse>> GetGestionaFieldsByExpediente(
|
public async Task<ActionResult<GestionaExternalFieldsResponse>> GetGestionaFieldsByExpediente(
|
||||||
int numeroExpediente,
|
int numeroExpediente,
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using ApiDenuncias.Configuration;
|
using ApiDenuncias.Configuration;
|
||||||
|
using ApiDenuncias.Security;
|
||||||
using ApiDenuncias.Services;
|
using ApiDenuncias.Services;
|
||||||
using ApiDenuncias.Configuration;
|
|
||||||
using GestionaDenuncias.Shared.Models;
|
using GestionaDenuncias.Shared.Models;
|
||||||
using ApiDenuncias.Services;
|
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Diagnostics;
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using GestionaDenuncias.Shared.Models;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Configuration.AddJsonFile(
|
||||||
|
$"appsettings.{builder.Environment.EnvironmentName}.local.json",
|
||||||
|
optional: true,
|
||||||
|
reloadOnChange: false);
|
||||||
|
|
||||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
|
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
|
||||||
builder.Services.Configure<KeyVaultOptions>(builder.Configuration.GetSection(KeyVaultOptions.SectionName));
|
builder.Services.Configure<KeyVaultOptions>(builder.Configuration.GetSection(KeyVaultOptions.SectionName));
|
||||||
builder.Services.Configure<GestionaOptions>(builder.Configuration.GetSection("Gestiona"));
|
builder.Services.Configure<GestionaOptions>(builder.Configuration.GetSection("Gestiona"));
|
||||||
builder.Services.Configure<GlobalLeaksOptions>(builder.Configuration.GetSection(GlobalLeaksOptions.SectionName));
|
builder.Services.Configure<GlobalLeaksOptions>(builder.Configuration.GetSection(GlobalLeaksOptions.SectionName));
|
||||||
builder.Services.Configure<ComplaintStorageOptions>(builder.Configuration.GetSection(ComplaintStorageOptions.SectionName));
|
builder.Services.Configure<ComplaintStorageOptions>(builder.Configuration.GetSection(ComplaintStorageOptions.SectionName));
|
||||||
builder.Services.Configure<ManualPurgeOptions>(builder.Configuration.GetSection(ManualPurgeOptions.SectionName));
|
builder.Services.Configure<ManualPurgeOptions>(builder.Configuration.GetSection(ManualPurgeOptions.SectionName));
|
||||||
|
builder.Services.Configure<OpeBridgeOptions>(builder.Configuration.GetSection(OpeBridgeOptions.SectionName));
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
@@ -48,6 +52,7 @@ builder.Services.AddScoped<UserComplaintAccessService>();
|
|||||||
builder.Services.AddScoped<WorkGroupAdministrationService>();
|
builder.Services.AddScoped<WorkGroupAdministrationService>();
|
||||||
builder.Services.AddHttpClient<ManualPurgeService>();
|
builder.Services.AddHttpClient<ManualPurgeService>();
|
||||||
builder.Services.AddScoped<AppConfigurationService>();
|
builder.Services.AddScoped<AppConfigurationService>();
|
||||||
|
builder.Services.AddScoped<OpeBridgeApiKeyFilter>();
|
||||||
|
|
||||||
builder.Services.AddHttpClient<IGestionaService, GestionaService>((sp, client) =>
|
builder.Services.AddHttpClient<IGestionaService, GestionaService>((sp, client) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<OpeBridgeApiKeyFilter> _logger;
|
||||||
|
|
||||||
|
public OpeBridgeApiKeyFilter(
|
||||||
|
IOptions<OpeBridgeOptions> options,
|
||||||
|
ILogger<OpeBridgeApiKeyFilter> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,5 +15,9 @@
|
|||||||
"ConnectionString": "Server=192.168.41.25;Port=13306;Database=gestiondenuncias;Uid=tecnosis;Pwd=tsl4net.Ts87;",
|
"ConnectionString": "Server=192.168.41.25;Port=13306;Database=gestiondenuncias;Uid=tecnosis;Pwd=tsl4net.Ts87;",
|
||||||
"UseKeyVault": false,
|
"UseKeyVault": false,
|
||||||
"AutoCreateSchema": true
|
"AutoCreateSchema": true
|
||||||
|
},
|
||||||
|
"OpeBridge": {
|
||||||
|
"ApiKey": "development-only-internal-ope-key-change-me",
|
||||||
|
"ApiKeyHeaderName": "X-ApiOPE-Key"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,5 +71,9 @@
|
|||||||
"DefaultPort": 3306,
|
"DefaultPort": 3306,
|
||||||
"DefaultSslMode": "Required",
|
"DefaultSslMode": "Required",
|
||||||
"AutoCreateSchema": true
|
"AutoCreateSchema": true
|
||||||
|
},
|
||||||
|
"OpeBridge": {
|
||||||
|
"ApiKey": "",
|
||||||
|
"ApiKeyHeaderName": "X-ApiOPE-Key"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
Antifraude.Net/ApiOPE.Tests/ApiOPE.Tests.csproj
Normal file
24
Antifraude.Net/ApiOPE.Tests/ApiOPE.Tests.csproj
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ApiOPE\ApiOPE.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
1
Antifraude.Net/ApiOPE.Tests/GlobalUsings.cs
Normal file
1
Antifraude.Net/ApiOPE.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
92
Antifraude.Net/ApiOPE.Tests/OpeFieldMapperTests.cs
Normal file
92
Antifraude.Net/ApiOPE.Tests/OpeFieldMapperTests.cs
Normal file
@@ -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<string, OpeFieldValue>
|
||||||
|
{
|
||||||
|
["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<string, OpeFieldValue>());
|
||||||
|
|
||||||
|
Assert.Throws<InvalidDataException>(() => 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs
Normal file
83
Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
164
Antifraude.Net/ApiOPE.Tests/OpeTokenValidatorTests.cs
Normal file
164
Antifraude.Net/ApiOPE.Tests/OpeTokenValidatorTests.cs
Normal file
@@ -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<string, object>
|
||||||
|
{
|
||||||
|
["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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Antifraude.Net/ApiOPE/ApiOPE.csproj
Normal file
17
Antifraude.Net/ApiOPE/ApiOPE.csproj
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="appsettings.*.local.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
39
Antifraude.Net/ApiOPE/ApiOPE.http
Normal file
39
Antifraude.Net/ApiOPE/ApiOPE.http
Normal file
@@ -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: <JWT resource=/versions/current>
|
||||||
|
X-Organization-ID: {{OrganizationId}}
|
||||||
|
X-Organization-DIR3: {{OrganizationDir3}}
|
||||||
|
X-Organization-CIF: {{OrganizationCif}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
GET {{ApiOPE_HostAddress}}/
|
||||||
|
X-Rest-Basic-Token: <JWT resource=/ version=1.0>
|
||||||
|
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: <JWT resource=/genericoperations version=1.0>
|
||||||
|
X-Organization-ID: {{OrganizationId}}
|
||||||
|
X-Organization-DIR3: {{OrganizationDir3}}
|
||||||
|
X-Organization-CIF: {{OrganizationCif}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"FIELD_0": {
|
||||||
|
"type": "STRING",
|
||||||
|
"value": "53/2026"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Antifraude.Net/ApiOPE/Configuration/InternalApiOptions.cs
Normal file
30
Antifraude.Net/ApiOPE/Configuration/InternalApiOptions.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
Antifraude.Net/ApiOPE/Configuration/OpeOptions.cs
Normal file
62
Antifraude.Net/ApiOPE/Configuration/OpeOptions.cs
Normal file
@@ -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<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Antifraude.Net/ApiOPE/Contracts/InternalApiContracts.cs
Normal file
6
Antifraude.Net/ApiOPE/Contracts/InternalApiContracts.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ApiOPE.Contracts;
|
||||||
|
|
||||||
|
public sealed record InternalGestionaFieldsResponse(
|
||||||
|
[property: JsonPropertyName("data")] IReadOnlyDictionary<string, OpeFieldValue> Data);
|
||||||
49
Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs
Normal file
49
Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs
Normal file
@@ -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<string, OpeFieldValue> 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<string, string>? 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";
|
||||||
|
}
|
||||||
164
Antifraude.Net/ApiOPE/Controllers/OpeController.cs
Normal file
164
Antifraude.Net/ApiOPE/Controllers/OpeController.cs
Normal file
@@ -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<OpeController> _logger;
|
||||||
|
|
||||||
|
public OpeController(
|
||||||
|
IOptions<OpeOptions> options,
|
||||||
|
InternalDenunciasClient internalApi,
|
||||||
|
OpeFieldMapper fieldMapper,
|
||||||
|
ILogger<OpeController> 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<string, string>(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<IActionResult> 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<string, string>(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<string, string>? 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>(
|
||||||
|
T value,
|
||||||
|
string contentType,
|
||||||
|
int statusCode = StatusCodes.Status200OK)
|
||||||
|
{
|
||||||
|
return new ContentResult
|
||||||
|
{
|
||||||
|
Content = JsonSerializer.Serialize(value, JsonOptions),
|
||||||
|
ContentType = contentType,
|
||||||
|
StatusCode = statusCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
43
Antifraude.Net/ApiOPE/Postman/INSTRUCCIONES-PRUEBA.txt
Normal file
43
Antifraude.Net/ApiOPE/Postman/INSTRUCCIONES-PRUEBA.txt
Normal file
@@ -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.
|
||||||
72
Antifraude.Net/ApiOPE/Program.cs
Normal file
72
Antifraude.Net/ApiOPE/Program.cs
Normal file
@@ -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<OpeOptions>()
|
||||||
|
.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<InternalApiOptions>()
|
||||||
|
.Bind(builder.Configuration.GetSection(InternalApiOptions.SectionName))
|
||||||
|
.Validate(
|
||||||
|
InternalApiOptionsValidator.IsValid,
|
||||||
|
"La configuracion InternalApi no es valida. Revisa BaseUrl, ApiKey y TimeoutSeconds.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<OpeResponseSigner>();
|
||||||
|
builder.Services.AddSingleton<OpeTokenValidator>();
|
||||||
|
builder.Services.AddSingleton<OpeFieldMapper>();
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient<InternalDenunciasClient>((services, client) =>
|
||||||
|
{
|
||||||
|
var options = services.GetRequiredService<IOptions<InternalApiOptions>>().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<OpeAuthenticationMiddleware>();
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
public partial class Program;
|
||||||
41
Antifraude.Net/ApiOPE/Properties/launchSettings.json
Normal file
41
Antifraude.Net/ApiOPE/Properties/launchSettings.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
Antifraude.Net/ApiOPE/README.md
Normal file
153
Antifraude.Net/ApiOPE/README.md
Normal file
@@ -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=<mismo valor que InternalApi__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`.
|
||||||
15
Antifraude.Net/ApiOPE/Security/OpeAuthenticatedAttribute.cs
Normal file
15
Antifraude.Net/ApiOPE/Security/OpeAuthenticatedAttribute.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
131
Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs
Normal file
131
Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs
Normal file
@@ -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<OpeAuthenticationMiddleware> _logger;
|
||||||
|
|
||||||
|
public OpeAuthenticationMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
ILogger<OpeAuthenticationMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(
|
||||||
|
HttpContext context,
|
||||||
|
OpeTokenValidator tokenValidator,
|
||||||
|
OpeResponseSigner responseSigner,
|
||||||
|
IOptions<OpeOptions> options)
|
||||||
|
{
|
||||||
|
var authentication = context.GetEndpoint()?.Metadata.GetMetadata<OpeAuthenticatedAttribute>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Antifraude.Net/ApiOPE/Security/OpeExceptionHandler.cs
Normal file
31
Antifraude.Net/ApiOPE/Security/OpeExceptionHandler.cs
Normal file
@@ -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<IExceptionHandlerFeature>()?.Error;
|
||||||
|
var requestContext = OpeRequestContextStore.Get(context);
|
||||||
|
var logger = context.RequestServices
|
||||||
|
.GetRequiredService<ILoggerFactory>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Antifraude.Net/ApiOPE/Security/OpeRequestContext.cs
Normal file
20
Antifraude.Net/ApiOPE/Security/OpeRequestContext.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
23
Antifraude.Net/ApiOPE/Security/OpeResponseSigner.cs
Normal file
23
Antifraude.Net/ApiOPE/Security/OpeResponseSigner.cs
Normal file
@@ -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<OpeOptions> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
33
Antifraude.Net/ApiOPE/Security/OpeResponseWriter.cs
Normal file
33
Antifraude.Net/ApiOPE/Security/OpeResponseWriter.cs
Normal file
@@ -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<string, string>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
238
Antifraude.Net/ApiOPE/Security/OpeTokenValidator.cs
Normal file
238
Antifraude.Net/ApiOPE/Security/OpeTokenValidator.cs
Normal file
@@ -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<OpeOptions> 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<string>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Antifraude.Net/ApiOPE/Services/DenunciaLookup.cs
Normal file
38
Antifraude.Net/ApiOPE/Services/DenunciaLookup.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
@@ -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<string, string> 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<GenericOperationRequestResult> ReadAsync(
|
||||||
|
HttpRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var errors = new Dictionary<string, string>(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<OpeDataEnvelope>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
Antifraude.Net/ApiOPE/Services/InternalDenunciasClient.cs
Normal file
98
Antifraude.Net/ApiOPE/Services/InternalDenunciasClient.cs
Normal file
@@ -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<InternalDenunciasClient> _logger;
|
||||||
|
|
||||||
|
public InternalDenunciasClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
ILogger<InternalDenunciasClient> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<InternalLookupResult> 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<InternalGestionaFieldsResponse>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Antifraude.Net/ApiOPE/Services/OpeFieldMapper.cs
Normal file
51
Antifraude.Net/ApiOPE/Services/OpeFieldMapper.cs
Normal file
@@ -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<string> SupportedInternalFields = new HashSet<string>(
|
||||||
|
[
|
||||||
|
"fechaDenuncia",
|
||||||
|
"numeroDenunciaCanal",
|
||||||
|
"aQuienDenuncia",
|
||||||
|
"resumenDenuncia",
|
||||||
|
"fechaHechos",
|
||||||
|
"lugarHechos",
|
||||||
|
"ambitoCompetencias",
|
||||||
|
"solicitaProteccion",
|
||||||
|
"sexoDenunciante",
|
||||||
|
"autorizaRemisionDenuncia",
|
||||||
|
"autorizaNotificacionesViaSms",
|
||||||
|
"preferenciaNotificacionSeguimientoDenuncia"
|
||||||
|
],
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private readonly string[] _outputFields;
|
||||||
|
|
||||||
|
public OpeFieldMapper(IOptions<OpeOptions> options)
|
||||||
|
{
|
||||||
|
_outputFields = options.Value.OutputFields.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public OpeDataEnvelope Map(InternalGestionaFieldsResponse source)
|
||||||
|
{
|
||||||
|
var output = new Dictionary<string, OpeFieldValue>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
Antifraude.Net/ApiOPE/appsettings.Development.json
Normal file
37
Antifraude.Net/ApiOPE/appsettings.Development.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Antifraude.Net/ApiOPE/appsettings.json
Normal file
28
Antifraude.Net/ApiOPE/appsettings.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user