316 lines
12 KiB
C#
316 lines
12 KiB
C#
|
|
using System.Text;
|
||
|
|
using System.Threading.RateLimiting;
|
||
|
|
using DoliMiddlewareApi.Exceptions;
|
||
|
|
using DoliMiddlewareApi.Services;
|
||
|
|
using DoliMiddlewareApi.Services.Auth;
|
||
|
|
using DoliMiddlewareApi.Services.Clients;
|
||
|
|
using DoliMiddlewareApi.Services.Notifications;
|
||
|
|
using DoliMiddlewareApi.Services.VeriFactu;
|
||
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||
|
|
using Microsoft.AspNetCore.Diagnostics;
|
||
|
|
using Microsoft.Extensions.Caching.Memory;
|
||
|
|
using Microsoft.IdentityModel.Tokens;
|
||
|
|
using Microsoft.OpenApi;
|
||
|
|
|
||
|
|
// =========================================
|
||
|
|
// PROGRAM.CS - CONFIGURACIÓN Y DEPENDENCIAS
|
||
|
|
// =========================================
|
||
|
|
|
||
|
|
var builder = WebApplication.CreateBuilder(args);
|
||
|
|
|
||
|
|
if (string.IsNullOrEmpty(builder.Configuration["Jwt:Secret"]))
|
||
|
|
throw new InvalidOperationException(
|
||
|
|
"JWT Secret is required. Set 'Jwt__Secret' environment variable, 'Jwt:Secret' in appsettings, or use 'dotnet user-secrets set Jwt:Secret <value>'.");
|
||
|
|
|
||
|
|
// =========================================
|
||
|
|
// 1. CONFIGURACIÓN DE SERVICIOS ASP.NET CORE
|
||
|
|
// =========================================
|
||
|
|
|
||
|
|
// Controllers + JSON (camelCase + enums as strings)
|
||
|
|
builder.Services.AddControllers()
|
||
|
|
.AddJsonOptions(options =>
|
||
|
|
{
|
||
|
|
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||
|
|
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||
|
|
});
|
||
|
|
|
||
|
|
// JWT Standard (ASP.NET auto-validation)
|
||
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||
|
|
.AddJwtBearer(options =>
|
||
|
|
{
|
||
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
||
|
|
{
|
||
|
|
ValidateIssuer = true,
|
||
|
|
ValidateAudience = true,
|
||
|
|
ValidateLifetime = true,
|
||
|
|
ValidateIssuerSigningKey = true,
|
||
|
|
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "DoliMiddleware",
|
||
|
|
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "DoliClients",
|
||
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
builder.Services.AddAuthorization();
|
||
|
|
|
||
|
|
// CORS: Permitir cualquier origen (demo)
|
||
|
|
builder.Services.AddCors(options =>
|
||
|
|
{
|
||
|
|
options.AddPolicy("AllowVueApp", policy =>
|
||
|
|
{
|
||
|
|
policy.AllowAnyOrigin()
|
||
|
|
.AllowAnyMethod()
|
||
|
|
.AllowAnyHeader();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Swagger/OpenAPI
|
||
|
|
builder.Services.AddEndpointsApiExplorer();
|
||
|
|
builder.Services.AddSwaggerGen(options =>
|
||
|
|
{
|
||
|
|
options.SwaggerDoc("v1", new OpenApiInfo
|
||
|
|
{
|
||
|
|
Title = "DoliMiddlewareApi",
|
||
|
|
Version = "1.0",
|
||
|
|
Description = "BFF API for Dolibarr ERP - Middleware between frontend and Dolibarr"
|
||
|
|
});
|
||
|
|
|
||
|
|
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||
|
|
{
|
||
|
|
Scheme = "bearer",
|
||
|
|
BearerFormat = "JWT",
|
||
|
|
Name = "Authorization",
|
||
|
|
In = ParameterLocation.Header,
|
||
|
|
Type = SecuritySchemeType.Http,
|
||
|
|
Description = "Enter your JWT Bearer token (obtained from POST /api/Auth/login)"
|
||
|
|
});
|
||
|
|
|
||
|
|
options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
|
||
|
|
{
|
||
|
|
[new OpenApiSecuritySchemeReference("Bearer", document)] = []
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Health checks (verifica que el BFF responde)
|
||
|
|
builder.Services.AddHealthChecks();
|
||
|
|
|
||
|
|
// Rate limiting: proteger login contra brute force
|
||
|
|
builder.Services.AddRateLimiter(options =>
|
||
|
|
{
|
||
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||
|
|
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||
|
|
{
|
||
|
|
var path = context.Request.Path.Value;
|
||
|
|
if (path != null && path.StartsWith("/api/Auth/login", StringComparison.OrdinalIgnoreCase))
|
||
|
|
{
|
||
|
|
return RateLimitPartition.GetSlidingWindowLimiter("login", _ => new SlidingWindowRateLimiterOptions
|
||
|
|
{
|
||
|
|
PermitLimit = 5,
|
||
|
|
Window = TimeSpan.FromMinutes(1),
|
||
|
|
SegmentsPerWindow = 2,
|
||
|
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||
|
|
QueueLimit = 0
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return RateLimitPartition.GetNoLimiter("default");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
|
||
|
|
// 2. DOLIBARR HTTP CLIENT
|
||
|
|
// =========================================
|
||
|
|
// HttpClient named para Dolibarr (configurado solo con BaseAddress)
|
||
|
|
// DolibarrApiClient se crea manual porque necesita HttpClient + TokenCacheService
|
||
|
|
//
|
||
|
|
// POR QUÉ NO usar AddHttpClient<TClient>() directamente?
|
||
|
|
// --------------------------------------------------------
|
||
|
|
// HttpClient es COMPARTIDO entre todos los usuarios
|
||
|
|
// Si configuramos el token ahí, el Usuario A usaría el token del Usuario B
|
||
|
|
// Solución: TokenCacheService obtiene el token del USUARIO ACTUAL (del JWT)
|
||
|
|
// factory.CreateClient("Dolibarr") devuelve el MISMO HttpClient reusado
|
||
|
|
// No se crea un cliente por request, los headers son por request (thread-safe)
|
||
|
|
// =========================================
|
||
|
|
|
||
|
|
builder.Services.AddHttpClient("Dolibarr", client =>
|
||
|
|
{
|
||
|
|
var baseUrl = builder.Configuration["Dolibarr:ApiUrl"]!;
|
||
|
|
if (!baseUrl.EndsWith('/')) baseUrl += '/';
|
||
|
|
client.BaseAddress = new Uri(baseUrl);
|
||
|
|
});
|
||
|
|
|
||
|
|
builder.Services.AddHttpClient("VeriFactu", client =>
|
||
|
|
{
|
||
|
|
var baseUrl = builder.Configuration["VeriFactu:ApiUrl"] ?? "http://localhost:6789/";
|
||
|
|
if (!baseUrl.EndsWith('/')) baseUrl += '/';
|
||
|
|
client.BaseAddress = new Uri(baseUrl);
|
||
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||
|
|
});
|
||
|
|
|
||
|
|
builder.Services.AddScoped<IDolibarrApiClient>(sp =>
|
||
|
|
{
|
||
|
|
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||
|
|
var client = factory.CreateClient("Dolibarr");
|
||
|
|
var tokenCacheService = sp.GetRequiredService<DolibarrTokenCacheService>();
|
||
|
|
var config = sp.GetRequiredService<IConfiguration>();
|
||
|
|
return new DolibarrApiClient(client, tokenCacheService, config);
|
||
|
|
});
|
||
|
|
|
||
|
|
// =========================================
|
||
|
|
// 3. REGISTRO DE SERVICIOS (DEPENDENCIAS)
|
||
|
|
// =========================================
|
||
|
|
|
||
|
|
// Servicio de negocio (facturas)
|
||
|
|
builder.Services.AddScoped<InvoiceService>();
|
||
|
|
|
||
|
|
// Servicio de negocio (clientes)
|
||
|
|
builder.Services.AddScoped<ClientService>();
|
||
|
|
|
||
|
|
// Servicio de negocio (contactos)
|
||
|
|
builder.Services.AddScoped<ContactService>();
|
||
|
|
|
||
|
|
// Servicio de negocio (documentos)
|
||
|
|
builder.Services.AddScoped<DocumentService>();
|
||
|
|
|
||
|
|
// Servicio de negocio (setup/diccionarios)
|
||
|
|
builder.Services.AddScoped<SetupService>();
|
||
|
|
|
||
|
|
// Cliente de VeriFactu MidAPI
|
||
|
|
builder.Services.AddScoped<VeriFactuApiClient>(sp =>
|
||
|
|
{
|
||
|
|
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||
|
|
return new VeriFactuApiClient(factory.CreateClient("VeriFactu"));
|
||
|
|
});
|
||
|
|
|
||
|
|
// Servicio de negocio (banco)
|
||
|
|
builder.Services.AddScoped<BankService>();
|
||
|
|
|
||
|
|
// Servicio de facturas de proveedores
|
||
|
|
builder.Services.AddScoped<SupplierInvoiceService>();
|
||
|
|
|
||
|
|
// Servicio de aplicación (orquesta login + cache)
|
||
|
|
builder.Services.AddScoped<AuthApplicationService>();
|
||
|
|
|
||
|
|
// Servicio de autenticación con Dolibarr
|
||
|
|
builder.Services.AddScoped<DolibarrAuthService>();
|
||
|
|
|
||
|
|
// Servicio de cache de tokens de Dolibarr
|
||
|
|
builder.Services.AddScoped<DolibarrTokenCacheService>();
|
||
|
|
|
||
|
|
// Generador de JWT - Singleton porque es stateless
|
||
|
|
builder.Services.AddSingleton<JwtTokenProvider>();
|
||
|
|
|
||
|
|
// Cache en memoria para tokens (IMemoryCache)
|
||
|
|
builder.Services.AddMemoryCache();
|
||
|
|
|
||
|
|
// HttpContext accessor para acceder a User.Claims en servicios
|
||
|
|
builder.Services.AddHttpContextAccessor();
|
||
|
|
|
||
|
|
builder.Services.AddHttpClient("Webhook", c => c.Timeout = TimeSpan.FromSeconds(8));
|
||
|
|
|
||
|
|
// =========================================
|
||
|
|
// NOTIFICACIONES — Webhook (Teams / Slack)
|
||
|
|
// Configura Notifications:WebhookUrl en appsettings o variable de entorno
|
||
|
|
// Si no está configurado, las notificaciones se ignoran silenciosamente
|
||
|
|
// WebhookSettings es singleton para permitir actualización en tiempo de ejecución
|
||
|
|
// =========================================
|
||
|
|
builder.Services.AddSingleton<WebhookSettings>();
|
||
|
|
builder.Services.AddScoped<INotificationService, WebhookNotificationService>();
|
||
|
|
|
||
|
|
var app = builder.Build();
|
||
|
|
|
||
|
|
// =========================================
|
||
|
|
// 4. GLOBAL EXCEPTION HANDLER
|
||
|
|
// =========================================
|
||
|
|
|
||
|
|
app.UseExceptionHandler(errorApp =>
|
||
|
|
{
|
||
|
|
errorApp.Run(async context =>
|
||
|
|
{
|
||
|
|
var exceptionHandler = context.Features.Get<IExceptionHandlerFeature>();
|
||
|
|
var exception = exceptionHandler?.Error;
|
||
|
|
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
||
|
|
var env = context.RequestServices.GetRequiredService<IWebHostEnvironment>();
|
||
|
|
|
||
|
|
// Clasificar el tipo de error
|
||
|
|
var (statusCode, title, detail, isExpectedError) = exception switch
|
||
|
|
{
|
||
|
|
NotFoundException notFound => (StatusCodes.Status404NotFound, "Not Found", notFound.Message, true),
|
||
|
|
UnauthorizedException => (StatusCodes.Status401Unauthorized, "Unauthorized", "Invalid API credentials", true),
|
||
|
|
ForbiddenException forbidden => (StatusCodes.Status403Forbidden, "Forbidden", forbidden.Message, true),
|
||
|
|
BadRequestException badRequest => (StatusCodes.Status400BadRequest, "Bad Request", badRequest.Message, true),
|
||
|
|
ApiException apiEx => (StatusCodes.Status500InternalServerError, "External Service Error",
|
||
|
|
env.IsDevelopment() ? apiEx.Message : "The external service is temporarily unavailable", false),
|
||
|
|
_ => (StatusCodes.Status500InternalServerError, "Internal Server Error",
|
||
|
|
env.IsDevelopment()
|
||
|
|
? exception?.Message ?? "An unexpected error occurred"
|
||
|
|
: "An unexpected error occurred. Please try again later.", false)
|
||
|
|
};
|
||
|
|
|
||
|
|
// Loguear siempre (crítico para debugging)
|
||
|
|
if (isExpectedError)
|
||
|
|
{
|
||
|
|
logger.LogWarning(exception,
|
||
|
|
"Expected error: {ExceptionType} | Path: {Path} | Message: {Message}",
|
||
|
|
exception?.GetType().Name, context.Request.Path, exception?.Message);
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
logger.LogError(exception,
|
||
|
|
"UNHANDLED EXCEPTION: {ExceptionType} | Path: {Path} | Message: {Message} | StackTrace: {StackTrace}",
|
||
|
|
exception?.GetType().Name, context.Request.Path, exception?.Message, exception?.StackTrace);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Construir respuesta ProblemDetails (RFC 7807)
|
||
|
|
context.Response.StatusCode = statusCode;
|
||
|
|
context.Response.ContentType = "application/problem+json";
|
||
|
|
|
||
|
|
var problemDetails = new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||
|
|
{
|
||
|
|
Status = statusCode,
|
||
|
|
Title = title,
|
||
|
|
Detail = detail,
|
||
|
|
Instance = context.Request.Path
|
||
|
|
};
|
||
|
|
|
||
|
|
// En desarrollo añadir info extra para debugging
|
||
|
|
if (env.IsDevelopment() && !isExpectedError)
|
||
|
|
{
|
||
|
|
problemDetails.Extensions["exceptionType"] = exception?.GetType().Name;
|
||
|
|
problemDetails.Extensions["stackTrace"] = exception?.StackTrace;
|
||
|
|
if (exception?.InnerException != null)
|
||
|
|
{
|
||
|
|
problemDetails.Extensions["innerException"] = exception.InnerException.Message;
|
||
|
|
problemDetails.Extensions["innerStackTrace"] = exception.InnerException.StackTrace;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
await context.Response.WriteAsJsonAsync(problemDetails);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// =========================================
|
||
|
|
// 5. PIPELINE ASP.NET CORE
|
||
|
|
// =========================================
|
||
|
|
|
||
|
|
app.UseSwagger();
|
||
|
|
app.UseSwaggerUI(options =>
|
||
|
|
{
|
||
|
|
options.SwaggerEndpoint("/swagger/v1/swagger.json", "DoliMiddlewareApi v1");
|
||
|
|
});
|
||
|
|
|
||
|
|
// HTTPS redirect solo si ForceHttps=true (nunca en Docker)
|
||
|
|
if (bool.TryParse(builder.Configuration["Dolibarr:ForceHttps"], out var forceHttps) && forceHttps)
|
||
|
|
app.UseHttpsRedirection();
|
||
|
|
|
||
|
|
app.UseCors("AllowVueApp");
|
||
|
|
|
||
|
|
app.UseRateLimiter();
|
||
|
|
|
||
|
|
app.UseAuthentication();
|
||
|
|
app.UseAuthorization();
|
||
|
|
|
||
|
|
app.MapControllers();
|
||
|
|
|
||
|
|
app.MapHealthChecks("/health");
|
||
|
|
|
||
|
|
app.Run();
|