Implementar autenticación JWT con caché de tokens de Dolibarr por sesión

- 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
This commit is contained in:
javiermengual 2026-01-03 19:52:07 +01:00
parent a80d41feab
commit 283a9e7f5f
12 changed files with 609 additions and 47 deletions

355
DoliMiddlewareApi/AGENTS.md Normal file
View File

@ -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<InvoiceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<List<InvoiceDto>>> 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<InvoiceDetailDto> GetInvoiceAsync(int id)
{
var data = await _apiClient.GetResourceAsync<InvoiceDetailResponse>($"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<CreateInvoiceLineDto> 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<DolibarrApiClient>(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<T> GetResourceAsync<T>(string endpoint) where T : class
{
var response = await _httpClient.GetAsync(endpoint);
await EnsureSuccessOrThrowAsync(response, endpoint);
return await response.Content.ReadFromJsonAsync<T>()
?? 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<DolibarrSettings>(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</content>
<parameter name="filePath">AGENTS.md

View File

@ -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<ActionResult<LoginResponse>> Login([FromBody] CreateTokenDto dto)
{
var result = await authAppService.LoginAsync(dto);
return Ok(result);
}
}

View File

@ -2,12 +2,14 @@ using System.ComponentModel.DataAnnotations;
using DoliMiddlewareApi.Dtos; using DoliMiddlewareApi.Dtos;
using DoliMiddlewareApi.Dtos.command; using DoliMiddlewareApi.Dtos.command;
using DoliMiddlewareApi.Services; using DoliMiddlewareApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DoliMiddlewareApi.Controllers; namespace DoliMiddlewareApi.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize]
public class InvoicesController : ControllerBase public class InvoicesController : ControllerBase
{ {
private readonly InvoiceService _invoiceService; private readonly InvoiceService _invoiceService;

View File

@ -9,8 +9,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,40 +1,115 @@
using DoliMiddlewareApi.Exceptions; using DoliMiddlewareApi.Exceptions;
using DoliMiddlewareApi.Services; using DoliMiddlewareApi.Services;
using DoliMiddlewareApi.Services.Clients; 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; using Microsoft.AspNetCore.Diagnostics;
// =========================================
// PROGRAM.CS - CONFIGURACIÓN Y DEPENDENCIAS
// =========================================
var builder = WebApplication.CreateBuilder(args); 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() builder.Services.AddControllers()
.AddJsonOptions(options => .AddJsonOptions(options =>
{ {
// Serializar enums como strings en vez de números
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); 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; 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.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
// Typed Client - Inyecta HttpClient directamente en DolibarrApiClient // 2. DOLIBARR HTTP CLIENT
// Registra la interfaz para poder hacer mock en tests // =========================================
builder.Services.AddHttpClient<IDolibarrApiClient, DolibarrApiClient>(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 =>
{ {
client.BaseAddress = new Uri(builder.Configuration["Dolibarr:ApiUrl"]!); client.BaseAddress = new Uri(builder.Configuration["Dolibarr:ApiUrl"]!);
client.DefaultRequestHeaders.Add("DOLAPIKEY", builder.Configuration["Dolibarr:ApiKey"]!);
}); });
// Registrar servicios de negocio builder.Services.AddScoped<IDolibarrApiClient>(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient("Dolibarr");
var tokenCacheService = sp.GetRequiredService<DolibarrTokenCacheService>();
return new DolibarrApiClient(client, tokenCacheService);
});
// =========================================
// 3. REGISTRO DE SERVICIOS (DEPENDENCIAS)
// =========================================
// Servicio de negocio (facturas)
builder.Services.AddScoped<InvoiceService>(); builder.Services.AddScoped<InvoiceService>();
// 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();
var app = builder.Build(); var app = builder.Build();
// Global exception handler // =========================================
// 4. GLOBAL EXCEPTION HANDLER
// =========================================
app.UseExceptionHandler(errorApp => app.UseExceptionHandler(errorApp =>
{ {
errorApp.Run(async context => errorApp.Run(async context =>
@ -44,44 +119,36 @@ app.UseExceptionHandler(errorApp =>
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>(); var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
var env = context.RequestServices.GetRequiredService<IWebHostEnvironment>(); var env = context.RequestServices.GetRequiredService<IWebHostEnvironment>();
// PASO 1: Clasificar el tipo de error // Clasificar el tipo de error
var (statusCode, title, detail, isExpectedError) = exception switch var (statusCode, title, detail, isExpectedError) = exception switch
{ {
// ERRORES ESPERADOS (del negocio/cliente) - OK mostrar detalles
NotFoundException notFound => (StatusCodes.Status404NotFound, "Not Found", notFound.Message, true), NotFoundException notFound => (StatusCodes.Status404NotFound, "Not Found", notFound.Message, true),
UnauthorizedException => (StatusCodes.Status401Unauthorized, "Unauthorized", "Invalid API credentials", true), UnauthorizedException => (StatusCodes.Status401Unauthorized, "Unauthorized", "Invalid API credentials", true),
ForbiddenException forbidden => (StatusCodes.Status403Forbidden, "Forbidden", forbidden.Message, true), ForbiddenException forbidden => (StatusCodes.Status403Forbidden, "Forbidden", forbidden.Message, true),
BadRequestException badRequest => (StatusCodes.Status400BadRequest, "Bad Request", badRequest.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), env.IsDevelopment() ? apiEx.Message : "The external service is temporarily unavailable", false),
// ERRORES INESPERADOS (bugs del programador)
_ => (StatusCodes.Status500InternalServerError, "Internal Server Error", _ => (StatusCodes.Status500InternalServerError, "Internal Server Error",
env.IsDevelopment() env.IsDevelopment()
? exception?.Message ?? "An unexpected error occurred" // DESARROLLO: muestra el error real ? exception?.Message ?? "An unexpected error occurred"
: "An unexpected error occurred. Please try again later.", // PRODUCCIÓN: mensaje genérico : "An unexpected error occurred. Please try again later.", false)
false)
}; };
// PASO 2: SIEMPRE loguear (crítico para debugging en producción) // Loguear siempre (crítico para debugging)
if (isExpectedError) 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}", "Expected error: {ExceptionType} | Path: {Path} | Message: {Message}",
exception?.GetType().Name, context.Request.Path, exception?.Message); exception?.GetType().Name, context.Request.Path, exception?.Message);
} }
else 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}", "UNHANDLED EXCEPTION: {ExceptionType} | Path: {Path} | Message: {Message} | StackTrace: {StackTrace}",
exception?.GetType().Name, context.Request.Path, exception?.Message, exception?.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.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json"; context.Response.ContentType = "application/problem+json";
@ -93,7 +160,7 @@ app.UseExceptionHandler(errorApp =>
Instance = context.Request.Path 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) if (env.IsDevelopment() && !isExpectedError)
{ {
problemDetails.Extensions["exceptionType"] = exception?.GetType().Name; 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()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();
@ -121,6 +191,7 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();

View File

@ -1,5 +1,6 @@
using DoliMiddlewareApi.Dtos.command; using DoliMiddlewareApi.Dtos.command;
using DoliMiddlewareApi.Dtos.Dolibarr; using DoliMiddlewareApi.Dtos.Dolibarr;
using DoliMiddlewareApi.Exceptions;
using DoliMiddlewareApi.Services.Clients; using DoliMiddlewareApi.Services.Clients;
using System.Text.Json; using System.Text.Json;
@ -17,13 +18,13 @@ public sealed class DolibarrAuthService(IDolibarrApiClient apiClient)
if (string.IsNullOrEmpty(responseString)) if (string.IsNullOrEmpty(responseString))
{ {
throw new UnauthorizedAccessException("Credenciales inválidas"); throw new UnauthorizedException("Credenciales inválidas");
} }
var response = JsonSerializer.Deserialize<TokenResponse>(responseString); var response = JsonSerializer.Deserialize<TokenResponse>(responseString);
if (response == null || string.IsNullOrEmpty(response.AccessToken)) 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; return response.AccessToken;

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<LoginResponse> 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; }
}

View File

@ -1,22 +1,27 @@
using System.Net; using System.Net;
using DoliMiddlewareApi.Exceptions; using DoliMiddlewareApi.Exceptions;
using DoliMiddlewareApi.Services.Auth;
namespace DoliMiddlewareApi.Services.Clients; namespace DoliMiddlewareApi.Services.Clients;
public class DolibarrApiClient : IDolibarrApiClient public class DolibarrApiClient : IDolibarrApiClient
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly DolibarrTokenCacheService _tokenCacheService;
public DolibarrApiClient(HttpClient httpClient) public DolibarrApiClient(HttpClient httpClient, DolibarrTokenCacheService tokenCacheService)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_tokenCacheService = tokenCacheService;
} }
// ===== MÉTODOS GENÉRICOS ===== // ===== MÉTODOS GENÉRICOS =====
public async Task<T> GetResourceAsync<T>(string endpoint) where T : class public async Task<T> GetResourceAsync<T>(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); await EnsureSuccessOrThrowAsync(response, endpoint);
return await response.Content.ReadFromJsonAsync<T>() return await response.Content.ReadFromJsonAsync<T>()
@ -25,7 +30,9 @@ public class DolibarrApiClient : IDolibarrApiClient
public async Task<List<T>> GetCollectionAsync<T>(string endpoint) where T : class public async Task<List<T>> GetCollectionAsync<T>(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); await EnsureSuccessOrThrowAsync(response, endpoint);
return await response.Content.ReadFromJsonAsync<List<T>>() return await response.Content.ReadFromJsonAsync<List<T>>()
@ -35,7 +42,12 @@ public class DolibarrApiClient : IDolibarrApiClient
public async Task<string> PostAsync(string endpoint, object requestBody) public async Task<string> 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); await EnsureSuccessOrThrowAsync(response, endpoint);
// porque devuelve el id como response // porque devuelve el id como response
@ -44,12 +56,26 @@ public class DolibarrApiClient : IDolibarrApiClient
public async Task<string> PutAsync(string endpoint, object requestBody) public async Task<string> 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); await EnsureSuccessOrThrowAsync(response, endpoint);
return await response.Content.ReadAsStringAsync(); return await response.Content.ReadAsStringAsync();
} }
private void AddDolibarrTokenHeader(HttpRequestMessage request)
{
var dolibarrToken = _tokenCacheService.GetDolibarrToken();
if (!string.IsNullOrEmpty(dolibarrToken))
{
request.Headers.Add("DOLAPIKEY", dolibarrToken);
}
}

View File

@ -10,5 +10,10 @@
"Dolibarr": { "Dolibarr": {
"ApiUrl": "", "ApiUrl": "",
"ApiKey": "" "ApiKey": ""
},
"Jwt": {
"Secret": "mi-super-secreto-jwt-aqui-cambiar-en-produccion",
"Issuer": "DoliMiddleware",
"Audience": "DoliClients"
} }
} }