From 283a9e7f5f6b3269ab4400d5bf5270e45c3af610 Mon Sep 17 00:00:00 2001 From: javiermengual Date: Sat, 3 Jan 2026 19:52:07 +0100 Subject: [PATCH] =?UTF-8?q?Implementar=20autenticaci=C3=B3n=20JWT=20con=20?= =?UTF-8?q?cach=C3=A9=20de=20tokens=20de=20Dolibarr=20por=20sesi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añadir autenticación estándar JWT Bearer con ASP.NET Core - Crear DolibarrTokenCacheService para la lógica de caché de tokens - Refactorizar DolibarrApiClient para usar TokenCacheService con SRP limpio - Actualizar Program.cs con registros de servicios y comentarios apropiados - Añadir AuthController para el endpoint de login - Actualizar InvoiceService para usar DolibarrApiClient --- DoliMiddlewareApi/AGENTS.md | 355 ++++++++++++++++++ .../Controllers/AuthController.cs | 17 + .../Controllers/InvoicesController.cs | 2 + DoliMiddlewareApi/DoliMiddlewareApi.csproj | 3 + DoliMiddlewareApi/Program.cs | 135 +++++-- .../Services/Auth/DolibarrAuthService.cs | 5 +- .../Auth/DolibarrTokenCacheService.cs | 27 ++ .../Services/Auth/JwtTokenProvider.cs | 31 ++ .../Services/AuthApplicationService.cs | 24 ++ .../Services/Clients/DolibarrApiClient.cs | 46 ++- DoliMiddlewareApi/Services/InvoiceService.cs | 6 +- DoliMiddlewareApi/appsettings.json | 5 + 12 files changed, 609 insertions(+), 47 deletions(-) create mode 100644 DoliMiddlewareApi/AGENTS.md create mode 100644 DoliMiddlewareApi/Controllers/AuthController.cs create mode 100644 DoliMiddlewareApi/Services/Auth/DolibarrTokenCacheService.cs create mode 100644 DoliMiddlewareApi/Services/Auth/JwtTokenProvider.cs create mode 100644 DoliMiddlewareApi/Services/AuthApplicationService.cs diff --git a/DoliMiddlewareApi/AGENTS.md b/DoliMiddlewareApi/AGENTS.md new file mode 100644 index 0000000..aa0e611 --- /dev/null +++ b/DoliMiddlewareApi/AGENTS.md @@ -0,0 +1,355 @@ +# AGENTS.md - Development Guidelines for DoliMiddlewareApi + +This file contains essential information for AI coding agents working on the DoliMiddlewareApi project. Follow these guidelines to maintain consistency and quality. + +## Project Overview +DoliMiddlewareApi is a C# ASP.NET Core Web API (targeting .NET 10.0) that serves as a middleware between client applications and Dolibarr ERP system. It provides invoice management functionality with RESTful endpoints. + +## Build/Lint/Test Commands + +### Building +```bash +# Build in Debug mode (default) +dotnet build + +# Build in Release mode +dotnet build -c Release + +# Clean and build +dotnet clean && dotnet build +``` + +### Running +```bash +# Run the application (includes hot reload in development) +dotnet run + +# Run with specific configuration +dotnet run --environment Development +``` + +### Linting/Formatting +```bash +# Format code according to project standards +dotnet format + +# Check formatting without making changes +dotnet format --verify-no-changes + +# Format whitespace only +dotnet format whitespace +``` + +### Testing +**Note**: This project currently has no test framework configured. When adding tests: + +```bash +# Add xUnit test framework (recommended) +dotnet add package xunit +dotnet add package Microsoft.NET.Test.Sdk +dotnet add package xunit.runner.visualstudio + +# Run tests (after setup) +dotnet test + +# Run tests with coverage (after adding coverlet.collector) +dotnet test --collect:"XPlat Code Coverage" + +# Run a specific test +dotnet test --filter "TestMethodName" + +# Run tests in a specific class +dotnet test --filter "ClassName" + +# Run tests in watch mode +dotnet watch test +``` + +### Docker +```bash +# Build Docker image +docker build -t dolimiddlewareapi . + +# Run Docker container +docker run -p 8080:8080 dolimiddlewareapi + +# Run with environment variables +docker run -p 8080:8080 -e ASPNETCORE_ENVIRONMENT=Development dolimiddlewareapi +``` + +## Code Style Guidelines + +### Language Features & Project Setup +- **Target Framework**: .NET 10.0 +- **Nullable Reference Types**: Enabled - use `?` for nullable types, avoid `!` suppressions +- **Implicit Usings**: Enabled - core namespaces are automatically imported +- **Top-level Statements**: Not used (traditional Program.cs structure) +- **Authentication**: JWT Bearer tokens with session-based caching + +### Naming Conventions +- **Classes**: PascalCase (e.g., `InvoiceService`, `CreateInvoiceDto`) +- **Methods**: PascalCase (e.g., `GetInvoiceAsync`, `MapToInvoiceDto`) +- **Properties**: PascalCase (e.g., `ClientId`, `TotalAmount`) +- **Private Fields**: camelCase with underscore prefix (e.g., `_httpClient`, `_invoiceService`) +- **Constants**: PascalCase (e.g., `DefaultPageSize`) +- **Namespaces**: Follow folder structure (e.g., `DoliMiddlewareApi.Controllers`) +- **Files**: Match class name (e.g., `InvoiceService.cs`) + +### Imports & Using Statements +```csharp +// Group usings by: +// 1. System namespaces +// 2. Microsoft namespaces +// 3. Third-party packages +// 4. Project namespaces +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using DoliMiddlewareApi.Dtos; +using DoliMiddlewareApi.Services; + +// Remove unused usings automatically with: dotnet format +``` + +### Formatting & Structure +- **Indentation**: 4 spaces (follow .editorconfig when created) +- **Braces**: K&R style (opening brace on same line) +- **Line Length**: Aim for 100-120 characters, break long lines appropriately +- **File Structure**: One class per file, except for small related classes + +### Controller Guidelines +```csharp +[ApiController] +[Route("api/[controller]")] +[Authorize] // JWT authentication required +public class InvoicesController : ControllerBase +{ + private readonly InvoiceService _invoiceService; + + // Constructor injection only + public InvoicesController(InvoiceService invoiceService) + { + _invoiceService = invoiceService; + } + + // Use ProducesResponseType for all endpoints + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task>> GetInvoices() => Ok(await _invoiceService.GetInvoicesAsync()); +} +``` + +### Service Layer Guidelines +```csharp +public class InvoiceService +{ + private readonly DolibarrApiClient _apiClient; + + public InvoiceService(DolibarrApiClient apiClient) + { + _apiClient = apiClient; + } + + // Async all the way - use Task for all public methods + public async Task GetInvoiceAsync(int id) + { + var data = await _apiClient.GetResourceAsync($"invoices/{id}"); + return InvoiceMapper.MapToInvoiceDetailDto(data); + } +} +``` + +### DTO Guidelines +```csharp +// Command DTOs (input) +public class CreateInvoiceDto +{ + [Required] + public int ClientId { get; set; } + + [Required] + public DateTime Date { get; set; } + + [StringLength(100)] + public string? Reference { get; set; } + + // Use meaningful defaults for optional properties + public string Status { get; set; } = "draft"; + + [Required] + [MinLength(1)] + public List Lines { get; set; } = new(); +} + +// Query DTOs (output) - handle nulls properly with nullable enabled +public class InvoiceDto +{ + public int Id { get; set; } + public string Number { get; set; } = ""; // Provide defaults for non-nullable properties + public DateTime? Date { get; set; } + public string Status { get; set; } = ""; +} +``` + +### Error Handling +```csharp +// Custom exceptions for business logic +public class NotFoundException : Exception +{ + public NotFoundException(string message) : base(message) { } +} + +// Use specific exceptions in services +if (invoice == null) + throw new NotFoundException($"Invoice with id {id} not found"); + +// Global error handling in Program.cs provides consistent API responses +// Logs errors appropriately based on type (expected vs unexpected) +``` + +### HTTP Client Usage +```csharp +// Use typed clients registered in DI +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri(builder.Configuration["Dolibarr:ApiUrl"]!); + client.DefaultRequestHeaders.Add("DOLAPIKEY", builder.Configuration["Dolibarr:ApiKey"]!); +}); + +// Generic methods for common operations +public async Task GetResourceAsync(string endpoint) where T : class +{ + var response = await _httpClient.GetAsync(endpoint); + await EnsureSuccessOrThrowAsync(response, endpoint); + return await response.Content.ReadFromJsonAsync() + ?? throw new ApiException($"Failed to deserialize response from Dolibarr for endpoint '{endpoint}'"); +} +``` + +### JSON Serialization +```csharp +// Configure in Program.cs for consistency +builder.Services.AddControllers() + .AddJsonOptions(options => + { + // Enums as strings + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + // camelCase properties + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }); +``` + +### Mapping Guidelines +```csharp +// Static mapper classes +public static class InvoiceMapper +{ + public static InvoiceDto MapToInvoiceDto(InvoiceResponse response) + { + return new InvoiceDto + { + Id = int.TryParse(response.id, out int id) ? id : 0, + Number = response.@ref ?? "SIN-REF", + // Handle parsing failures gracefully + Total = decimal.TryParse(response.total_ttc, NumberStyles.Any, CultureInfo.InvariantCulture, + out decimal total) ? Math.Round(total, 2) : null, + Status = ConvertStatusToWord(response.statut) + }; + } + + // Private helper methods for complex conversions + private static string ConvertStatusToWord(string? statusNumber) + { + return statusNumber switch + { + "0" => "draft", + "1" => "unpaid", + "2" => "paid", + "3" => "cancelled", + _ => "unknown" + }; + } +} +``` + +### Configuration Management +```csharp +// Strongly typed configuration classes +public class DolibarrSettings +{ + public string ApiUrl { get; set; } = ""; + public string ApiKey { get; set; } = ""; +} + +// Register in Program.cs +builder.Services.Configure(builder.Configuration.GetSection("Dolibarr")); +``` + +### Security Best Practices +- Never log sensitive data (API keys, passwords) +- Use proper validation attributes on DTOs +- Validate all input parameters +- Use HTTPS in production +- Implement proper authentication/authorization when needed + +### Performance Considerations +- Use async/await throughout the stack +- Avoid blocking calls in async methods +- Use dependency injection for proper lifetime management +- Consider response caching for read operations +- Use pagination for list endpoints + +### Logging Guidelines +```csharp +// Use structured logging with semantic parameters +_logger.LogInformation("Processing invoice creation for client {ClientId}", clientId); + +// Log levels: +// - Information: Normal operations +// - Warning: Expected errors (business logic failures) +// - Error: Unexpected errors (bugs) +// - Debug: Detailed debugging information (development only) +``` + +## Development Workflow + +1. **Before making changes**: Run `dotnet build` to ensure current state compiles +2. **Make changes**: Follow the established patterns and conventions +3. **Format code**: Run `dotnet format` to ensure consistent formatting +4. **Test changes**: Run the application and test endpoints manually +5. **Build verification**: Run `dotnet build -c Release` before committing + +## Architecture Patterns Used + +- **Clean Architecture**: Controllers → Services → External APIs +- **Dependency Injection**: Constructor injection throughout +- **Repository Pattern**: Not implemented (direct API calls) +- **CQRS**: Separated command/query DTOs +- **Mapper Pattern**: Static mappers for data transformation +- **Exception Handling**: Global error handling middleware +- **Service Layer Separation**: Each service has a single responsibility (SRP) + - `DolibarrTokenCacheService`: Token caching (no HTTP calls) + - `DolibarrApiClient`: HTTP calls only (no cache logic) + - `InvoiceService`: Business logic (uses cached tokens via HTTP client) + - `AuthApplicationService`: Orchestrates login + token caching + - `DolibarrAuthService`: Authenticates with external API + +## Future Considerations + +When adding tests: +- Use xUnit as the testing framework +- Add integration tests for API endpoints +- Mock external API calls using Moq or NSubstitute +- Aim for high test coverage on business logic + +When adding authentication: +- JWT token validation with Microsoft.AspNetCore.Authentication.JwtBearer +- Session-based caching for Dolibarr API tokens (handled in DolibarrTokenCacheService) +- [Authorize] attributes on protected controllers + +When scaling: +- Consider adding response caching +- Implement rate limiting +- Add request/response logging middleware +- Consider API versioning strategy +AGENTS.md \ No newline at end of file diff --git a/DoliMiddlewareApi/Controllers/AuthController.cs b/DoliMiddlewareApi/Controllers/AuthController.cs new file mode 100644 index 0000000..fbebebd --- /dev/null +++ b/DoliMiddlewareApi/Controllers/AuthController.cs @@ -0,0 +1,17 @@ +using DoliMiddlewareApi.Dtos.command; +using DoliMiddlewareApi.Services; +using Microsoft.AspNetCore.Mvc; + +namespace DoliMiddlewareApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController(AuthApplicationService authAppService) : ControllerBase +{ + [HttpPost("login")] + public async Task> Login([FromBody] CreateTokenDto dto) + { + var result = await authAppService.LoginAsync(dto); + return Ok(result); + } +} \ No newline at end of file diff --git a/DoliMiddlewareApi/Controllers/InvoicesController.cs b/DoliMiddlewareApi/Controllers/InvoicesController.cs index 20b81c7..ebbb668 100644 --- a/DoliMiddlewareApi/Controllers/InvoicesController.cs +++ b/DoliMiddlewareApi/Controllers/InvoicesController.cs @@ -2,12 +2,14 @@ using System.ComponentModel.DataAnnotations; using DoliMiddlewareApi.Dtos; using DoliMiddlewareApi.Dtos.command; using DoliMiddlewareApi.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace DoliMiddlewareApi.Controllers; [ApiController] [Route("api/[controller]")] +[Authorize] public class InvoicesController : ControllerBase { private readonly InvoiceService _invoiceService; diff --git a/DoliMiddlewareApi/DoliMiddlewareApi.csproj b/DoliMiddlewareApi/DoliMiddlewareApi.csproj index 1607f65..d112c61 100644 --- a/DoliMiddlewareApi/DoliMiddlewareApi.csproj +++ b/DoliMiddlewareApi/DoliMiddlewareApi.csproj @@ -9,8 +9,11 @@ + + + diff --git a/DoliMiddlewareApi/Program.cs b/DoliMiddlewareApi/Program.cs index 0b9557a..36f8693 100644 --- a/DoliMiddlewareApi/Program.cs +++ b/DoliMiddlewareApi/Program.cs @@ -1,40 +1,115 @@ using DoliMiddlewareApi.Exceptions; using DoliMiddlewareApi.Services; using DoliMiddlewareApi.Services.Clients; +using DoliMiddlewareApi.Services.Auth; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; +using Microsoft.Extensions.Caching.Memory; using Microsoft.AspNetCore.Diagnostics; +// ========================================= +// PROGRAM.CS - CONFIGURACIÓN Y DEPENDENCIAS +// ========================================= + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. +if (string.IsNullOrEmpty(builder.Configuration["Jwt:Secret"])) + throw new InvalidOperationException("JWT Secret is required in configuration. Set 'Jwt:Secret' in appsettings.json or environment variables."); +// ========================================= +// 1. CONFIGURACIÓN DE SERVICIOS ASP.NET CORE +// ========================================= + +// Controllers + JSON (camelCase + enums as strings) builder.Services.AddControllers() .AddJsonOptions(options => { - // Serializar enums como strings en vez de números options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); - - // Usar camelCase para propiedades (id en vez de Id) options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; }); -// Swagger/OpenAPI con Swashbuckle (genera schemas completos) + +// 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(); + +// Swagger/OpenAPI builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -// Typed Client - Inyecta HttpClient directamente en DolibarrApiClient -// Registra la interfaz para poder hacer mock en tests -builder.Services.AddHttpClient(client => +// 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() 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 => { client.BaseAddress = new Uri(builder.Configuration["Dolibarr:ApiUrl"]!); - client.DefaultRequestHeaders.Add("DOLAPIKEY", builder.Configuration["Dolibarr:ApiKey"]!); }); -// Registrar servicios de negocio +builder.Services.AddScoped(sp => +{ + var factory = sp.GetRequiredService(); + var client = factory.CreateClient("Dolibarr"); + var tokenCacheService = sp.GetRequiredService(); + return new DolibarrApiClient(client, tokenCacheService); +}); + +// ========================================= +// 3. REGISTRO DE SERVICIOS (DEPENDENCIAS) +// ========================================= + +// Servicio de negocio (facturas) builder.Services.AddScoped(); +// Servicio de aplicación (orquesta login + cache) +builder.Services.AddScoped(); + +// Servicio de autenticación con Dolibarr +builder.Services.AddScoped(); + +// Servicio de cache de tokens de Dolibarr +builder.Services.AddScoped(); + +// Generador de JWT - Singleton porque es stateless +builder.Services.AddSingleton(); + +// Cache en memoria para tokens (IMemoryCache) +builder.Services.AddMemoryCache(); + +// HttpContext accessor para acceder a User.Claims en servicios +builder.Services.AddHttpContextAccessor(); + var app = builder.Build(); -// Global exception handler +// ========================================= +// 4. GLOBAL EXCEPTION HANDLER +// ========================================= + app.UseExceptionHandler(errorApp => { errorApp.Run(async context => @@ -44,44 +119,36 @@ app.UseExceptionHandler(errorApp => var logger = context.RequestServices.GetRequiredService>(); var env = context.RequestServices.GetRequiredService(); - // PASO 1: Clasificar el tipo de error + // Clasificar el tipo de error var (statusCode, title, detail, isExpectedError) = exception switch { - // ERRORES ESPERADOS (del negocio/cliente) - OK mostrar detalles 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), - - // ERROR DE API EXTERNA (Dolibarr) - Mostrar que es externo pero no detalles internos - ApiException apiEx => (StatusCodes.Status500InternalServerError, "External Service Error", + ApiException apiEx => (StatusCodes.Status500InternalServerError, "External Service Error", env.IsDevelopment() ? apiEx.Message : "The external service is temporarily unavailable", false), - - // ERRORES INESPERADOS (bugs del programador) - _ => (StatusCodes.Status500InternalServerError, "Internal Server Error", - env.IsDevelopment() - ? exception?.Message ?? "An unexpected error occurred" // DESARROLLO: muestra el error real - : "An unexpected error occurred. Please try again later.", // PRODUCCIÓN: mensaje genérico - false) + _ => (StatusCodes.Status500InternalServerError, "Internal Server Error", + env.IsDevelopment() + ? exception?.Message ?? "An unexpected error occurred" + : "An unexpected error occurred. Please try again later.", false) }; - // PASO 2: SIEMPRE loguear (crítico para debugging en producción) + // Loguear siempre (crítico para debugging) if (isExpectedError) { - // Errores esperados: log como Warning (no son bugs, son flujo normal) - logger.LogWarning(exception, + logger.LogWarning(exception, "Expected error: {ExceptionType} | Path: {Path} | Message: {Message}", exception?.GetType().Name, context.Request.Path, exception?.Message); } else { - // Errores inesperados: log como Error (son bugs que HAY QUE ARREGLAR) - logger.LogError(exception, + logger.LogError(exception, "UNHANDLED EXCEPTION: {ExceptionType} | Path: {Path} | Message: {Message} | StackTrace: {StackTrace}", exception?.GetType().Name, context.Request.Path, exception?.Message, exception?.StackTrace); } - // PASO 3: Construir respuesta ProblemDetails (estándar RFC 7807) + // Construir respuesta ProblemDetails (RFC 7807) context.Response.StatusCode = statusCode; context.Response.ContentType = "application/problem+json"; @@ -93,7 +160,7 @@ app.UseExceptionHandler(errorApp => Instance = context.Request.Path }; - // PASO 4: EN DESARROLLO añadir info extra para debugging + // En desarrollo añadir info extra para debugging if (env.IsDevelopment() && !isExpectedError) { problemDetails.Extensions["exceptionType"] = exception?.GetType().Name; @@ -109,7 +176,10 @@ app.UseExceptionHandler(errorApp => }); }); -// Configure the HTTP request pipeline. +// ========================================= +// 5. PIPELINE ASP.NET CORE +// ========================================= + if (app.Environment.IsDevelopment()) { app.UseSwagger(); @@ -121,8 +191,9 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/DoliMiddlewareApi/Services/Auth/DolibarrAuthService.cs b/DoliMiddlewareApi/Services/Auth/DolibarrAuthService.cs index b7cfdec..b55d16b 100644 --- a/DoliMiddlewareApi/Services/Auth/DolibarrAuthService.cs +++ b/DoliMiddlewareApi/Services/Auth/DolibarrAuthService.cs @@ -1,5 +1,6 @@ using DoliMiddlewareApi.Dtos.command; using DoliMiddlewareApi.Dtos.Dolibarr; +using DoliMiddlewareApi.Exceptions; using DoliMiddlewareApi.Services.Clients; using System.Text.Json; @@ -17,13 +18,13 @@ public sealed class DolibarrAuthService(IDolibarrApiClient apiClient) if (string.IsNullOrEmpty(responseString)) { - throw new UnauthorizedAccessException("Credenciales inválidas"); + throw new UnauthorizedException("Credenciales inválidas"); } var response = JsonSerializer.Deserialize(responseString); if (response == null || string.IsNullOrEmpty(response.AccessToken)) { - throw new UnauthorizedAccessException("Respuesta inválida de Dolibarr"); + throw new UnauthorizedException("Respuesta inválida de Dolibarr"); } return response.AccessToken; diff --git a/DoliMiddlewareApi/Services/Auth/DolibarrTokenCacheService.cs b/DoliMiddlewareApi/Services/Auth/DolibarrTokenCacheService.cs new file mode 100644 index 0000000..530c1bd --- /dev/null +++ b/DoliMiddlewareApi/Services/Auth/DolibarrTokenCacheService.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; + +namespace DoliMiddlewareApi.Services.Auth; + +public class DolibarrTokenCacheService(IHttpContextAccessor httpContextAccessor, IMemoryCache cache) +{ + + public string? GetDolibarrToken() + { + var context = httpContextAccessor.HttpContext; + if (context?.User.Identity?.IsAuthenticated == true) + { + var sessionIdClaim = context.User.Claims.FirstOrDefault(c => c.Type == "sessionId"); + if (sessionIdClaim != null && cache.TryGetValue(sessionIdClaim.Value, out string? dolibarrToken)) + { + return dolibarrToken; + } + } + return null; + } + + public void SetDolibarrToken(string sessionId, string dolibarrToken, TimeSpan expiration) + { + cache.Set(sessionId, dolibarrToken, expiration); + } +} \ No newline at end of file diff --git a/DoliMiddlewareApi/Services/Auth/JwtTokenProvider.cs b/DoliMiddlewareApi/Services/Auth/JwtTokenProvider.cs new file mode 100644 index 0000000..4c41d8a --- /dev/null +++ b/DoliMiddlewareApi/Services/Auth/JwtTokenProvider.cs @@ -0,0 +1,31 @@ +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace DoliMiddlewareApi.Services.Auth; + +public sealed class JwtTokenProvider(IConfiguration config) +{ + public string GenerateJwt(string sessionId, string username) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Secret"]!)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim("sessionId", sessionId), + new Claim(ClaimTypes.Name, username) + }; + + var token = new JwtSecurityToken( + issuer: config["Jwt:Issuer"] ?? "DoliMiddleware", + audience: config["Jwt:Audience"] ?? "DoliClients", + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/DoliMiddlewareApi/Services/AuthApplicationService.cs b/DoliMiddlewareApi/Services/AuthApplicationService.cs new file mode 100644 index 0000000..1f524c2 --- /dev/null +++ b/DoliMiddlewareApi/Services/AuthApplicationService.cs @@ -0,0 +1,24 @@ +using DoliMiddlewareApi.Dtos.command; +using DoliMiddlewareApi.Services.Auth; + +namespace DoliMiddlewareApi.Services; + +public class AuthApplicationService(DolibarrAuthService dolibarrAuth, JwtTokenProvider jwtProvider, DolibarrTokenCacheService tokenCacheService) +{ + public async Task LoginAsync(CreateTokenDto dto) + { + var doliToken = await dolibarrAuth.AuthenticateAsync(dto); + var sessionId = Guid.NewGuid().ToString(); + + tokenCacheService.SetDolibarrToken(sessionId, doliToken, TimeSpan.FromMinutes(30)); + + var jwt = jwtProvider.GenerateJwt(sessionId, dto.Username); + + return new LoginResponse { Token = jwt }; + } +} + +public class LoginResponse +{ + public required string Token { get; set; } +} \ No newline at end of file diff --git a/DoliMiddlewareApi/Services/Clients/DolibarrApiClient.cs b/DoliMiddlewareApi/Services/Clients/DolibarrApiClient.cs index f87b200..ba7f756 100644 --- a/DoliMiddlewareApi/Services/Clients/DolibarrApiClient.cs +++ b/DoliMiddlewareApi/Services/Clients/DolibarrApiClient.cs @@ -1,22 +1,27 @@ using System.Net; using DoliMiddlewareApi.Exceptions; +using DoliMiddlewareApi.Services.Auth; namespace DoliMiddlewareApi.Services.Clients; public class DolibarrApiClient : IDolibarrApiClient { private readonly HttpClient _httpClient; + private readonly DolibarrTokenCacheService _tokenCacheService; - public DolibarrApiClient(HttpClient httpClient) + public DolibarrApiClient(HttpClient httpClient, DolibarrTokenCacheService tokenCacheService) { _httpClient = httpClient; + _tokenCacheService = tokenCacheService; } - - + + // ===== MÉTODOS GENÉRICOS ===== public async Task GetResourceAsync(string endpoint) where T : class { - var response = await _httpClient.GetAsync(endpoint); + var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + AddDolibarrTokenHeader(request); + var response = await _httpClient.SendAsync(request); await EnsureSuccessOrThrowAsync(response, endpoint); return await response.Content.ReadFromJsonAsync() @@ -25,30 +30,51 @@ public class DolibarrApiClient : IDolibarrApiClient public async Task> GetCollectionAsync(string endpoint) where T : class { - var response = await _httpClient.GetAsync(endpoint); + var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + AddDolibarrTokenHeader(request); + var response = await _httpClient.SendAsync(request); await EnsureSuccessOrThrowAsync(response, endpoint); return await response.Content.ReadFromJsonAsync>() ?? throw new ApiException( $"Failed to deserialize list response from Dolibarr for endpoint '{endpoint}'"); } - + public async Task PostAsync(string endpoint, object requestBody) { - var response = await _httpClient.PostAsJsonAsync(endpoint, requestBody); + var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = JsonContent.Create(requestBody) + }; + AddDolibarrTokenHeader(request); + var response = await _httpClient.SendAsync(request); await EnsureSuccessOrThrowAsync(response, endpoint); // porque devuelve el id como response return await response.Content.ReadAsStringAsync(); } - + public async Task PutAsync(string endpoint, object requestBody) { - var response = await _httpClient.PutAsJsonAsync(endpoint, requestBody); + var request = new HttpRequestMessage(HttpMethod.Put, endpoint) + { + Content = JsonContent.Create(requestBody) + }; + AddDolibarrTokenHeader(request); + var response = await _httpClient.SendAsync(request); await EnsureSuccessOrThrowAsync(response, endpoint); - + return await response.Content.ReadAsStringAsync(); } + + private void AddDolibarrTokenHeader(HttpRequestMessage request) + { + var dolibarrToken = _tokenCacheService.GetDolibarrToken(); + if (!string.IsNullOrEmpty(dolibarrToken)) + { + request.Headers.Add("DOLAPIKEY", dolibarrToken); + } + } diff --git a/DoliMiddlewareApi/Services/InvoiceService.cs b/DoliMiddlewareApi/Services/InvoiceService.cs index 73a8201..26a2aa9 100644 --- a/DoliMiddlewareApi/Services/InvoiceService.cs +++ b/DoliMiddlewareApi/Services/InvoiceService.cs @@ -48,8 +48,8 @@ public class InvoiceService type = "0", statut = dto.Status == "unpaid" ? "1" : "0", date = ((DateTimeOffset)dto.Date).ToUnixTimeSeconds().ToString(), - date_lim_reglement = dto.ExpireDate.HasValue - ? ((DateTimeOffset)dto.ExpireDate.Value).ToUnixTimeSeconds().ToString() + date_lim_reglement = dto.ExpireDate.HasValue + ? ((DateTimeOffset)dto.ExpireDate.Value).ToUnixTimeSeconds().ToString() : null, @ref = dto.Reference, note_public = dto.NotePublic, @@ -64,7 +64,7 @@ public class InvoiceService }; var responseBody = await _apiClient.PostAsync("invoices", requestBody); - + return int.Parse(responseBody); } diff --git a/DoliMiddlewareApi/appsettings.json b/DoliMiddlewareApi/appsettings.json index 58ebdc7..c4da293 100644 --- a/DoliMiddlewareApi/appsettings.json +++ b/DoliMiddlewareApi/appsettings.json @@ -10,5 +10,10 @@ "Dolibarr": { "ApiUrl": "", "ApiKey": "" + }, + "Jwt": { + "Secret": "mi-super-secreto-jwt-aqui-cambiar-en-produccion", + "Issuer": "DoliMiddleware", + "Audience": "DoliClients" } } \ No newline at end of file