First Commit
This commit is contained in:
22
Apis/ADS.Apis.Gps/ADS.Apis.Gps.csproj
Normal file
22
Apis/ADS.Apis.Gps/ADS.Apis.Gps.csproj
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Helpers\ADS.Helpers.Middleware\ADS.Helpers.Middleware.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Helpers\ADS.Helpers.Data\ADS.Helpers.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Helpers\ADS.Helpers.Auth\ADS.Helpers.Auth.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
Apis/ADS.Apis.Gps/ADS.Apis.Gps.http
Normal file
6
Apis/ADS.Apis.Gps/ADS.Apis.Gps.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@ADS.Apis.Gps_HostAddress = http://localhost:5286
|
||||||
|
|
||||||
|
GET {{ADS.Apis.Gps_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ADS.Apis.Gps.Application.Abstractions.Security;
|
||||||
|
|
||||||
|
public interface IAdsClientCredentialValidator
|
||||||
|
{
|
||||||
|
Task<AdsClientCredentialResult> ValidateAsync(string clientId, string clientSecret, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AdsClientCredentialResult(
|
||||||
|
bool IsValid,
|
||||||
|
int? ClientId,
|
||||||
|
string? ClientKey,
|
||||||
|
string? ClientName
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ADS.Apis.Gps.Application.Abstractions.Security;
|
||||||
|
|
||||||
|
public interface IAdsUserCredentialValidator
|
||||||
|
{
|
||||||
|
Task<AdsUserCredentialResult> ValidateAsync(string username, string password, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AdsUserCredentialResult(
|
||||||
|
bool IsValid,
|
||||||
|
int? UserId,
|
||||||
|
string? Username,
|
||||||
|
string? DisplayName
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ADS.Apis.Gps.Application.Common.Crypto;
|
||||||
|
|
||||||
|
public interface ILegacyPasswordEncryptor
|
||||||
|
{
|
||||||
|
string Encrypt(string plainText);
|
||||||
|
}
|
||||||
38
Apis/ADS.Apis.Gps/Application/Common/Crypto/Md5Hex32.cs
Normal file
38
Apis/ADS.Apis.Gps/Application/Common/Crypto/Md5Hex32.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Application.Common.Crypto;
|
||||||
|
|
||||||
|
public static class Md5Hex32
|
||||||
|
{
|
||||||
|
public static bool IsMd5Hex32(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value) || value.Length != 32) return false;
|
||||||
|
|
||||||
|
for (var i = 0; i < value.Length; i++)
|
||||||
|
{
|
||||||
|
var c = value[i];
|
||||||
|
var isHex =
|
||||||
|
(c >= '0' && c <= '9') ||
|
||||||
|
(c >= 'a' && c <= 'f') ||
|
||||||
|
(c >= 'A' && c <= 'F');
|
||||||
|
|
||||||
|
if (!isHex) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToMd5Hex32(string plainText)
|
||||||
|
{
|
||||||
|
using var md5 = MD5.Create();
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(plainText ?? string.Empty);
|
||||||
|
var hash = md5.ComputeHash(bytes);
|
||||||
|
|
||||||
|
var sb = new StringBuilder(32);
|
||||||
|
foreach (var b in hash)
|
||||||
|
sb.Append(b.ToString("x2"));
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ADS.Apis.Gps.Application.Dtos.Auth;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Application.Contracts.Auth;
|
||||||
|
|
||||||
|
public interface ISicrepAuthService
|
||||||
|
{
|
||||||
|
Task<SicrepAuthResponse> CreateTokenAsync(SicrepAuthRequest request, string? ip, string? mac, string? grupoEmpresa, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ADS.Apis.Gps.Contracts.Common;
|
||||||
|
|
||||||
|
public sealed class ApiResponse<T>
|
||||||
|
{
|
||||||
|
public int CountRecords { get; init; }
|
||||||
|
public int StatusCode { get; init; }
|
||||||
|
public string StatusCodeText { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? ConsoleMessage { get; init; } // mensaje consola
|
||||||
|
public UiMessage? ScreenMessage { get; init; } // mensaje pantalla
|
||||||
|
|
||||||
|
public T? Value { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UiMessage
|
||||||
|
{
|
||||||
|
public int MessageType { get; init; } // tipoMensaje
|
||||||
|
public string Message { get; init; } = ""; // mensaje
|
||||||
|
public int MessageCode { get; init; } // codigoMsg
|
||||||
|
}
|
||||||
11
Apis/ADS.Apis.Gps/Application/Contracts/Common/PagedQuery.cs
Normal file
11
Apis/ADS.Apis.Gps/Application/Contracts/Common/PagedQuery.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ADS.Apis.Gps.Contracts.Common;
|
||||||
|
|
||||||
|
public sealed class PagedQuery
|
||||||
|
{
|
||||||
|
public string? Search { get; init; }
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
public int PageSize { get; init; } = 50;
|
||||||
|
|
||||||
|
// Si Pagination=false, tu servicio puede ignorar Page/PageSize y devolver todo (o un límite fijo).
|
||||||
|
public bool Pagination { get; init; } = true;
|
||||||
|
}
|
||||||
12
Apis/ADS.Apis.Gps/Application/Contracts/Common/RequestOf.cs
Normal file
12
Apis/ADS.Apis.Gps/Application/Contracts/Common/RequestOf.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ADS.Apis.Gps.Contracts.Common;
|
||||||
|
|
||||||
|
public sealed class RequestOf<T>
|
||||||
|
{
|
||||||
|
public T? Value { get; init; }
|
||||||
|
|
||||||
|
// Si también quieres paginación en algunos body requests:
|
||||||
|
public string? Search { get; init; }
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
public int PageSize { get; init; } = 50;
|
||||||
|
public bool Pagination { get; init; } = true;
|
||||||
|
}
|
||||||
10
Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthRequest.cs
Normal file
10
Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthRequest.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ADS.Apis.Gps.Application.Dtos.Auth;
|
||||||
|
|
||||||
|
public sealed class SicrepAuthRequest
|
||||||
|
{
|
||||||
|
public string Usuario { get; set; } = string.Empty;
|
||||||
|
public string Contrasena { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Opcional: si quieres forzar si viene en MD5 o plaintext
|
||||||
|
// public bool IsMd5Hex32 { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ADS.Apis.Gps.Application.Dtos.Auth;
|
||||||
|
|
||||||
|
public sealed class SicrepAuthResponse
|
||||||
|
{
|
||||||
|
public string AccessToken { get; init; } = string.Empty;
|
||||||
|
public string TokenType { get; init; } = "Bearer";
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ADS.Apis.Gps.Application.Units;
|
||||||
|
|
||||||
|
public interface IUnitLocationQueryService
|
||||||
|
{
|
||||||
|
Task<UnitLastLocationResponse?> GetLastLocationByNoEconomicoAsync(
|
||||||
|
int cveUsuario,
|
||||||
|
string noEconomico,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<UnitClientLocationResponse>> GetLocationsByClientAsync(
|
||||||
|
int cveUsuario,
|
||||||
|
int cveCliente,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ADS.Apis.Gps.Application.Units;
|
||||||
|
|
||||||
|
public sealed class UnitClientLocationResponse
|
||||||
|
{
|
||||||
|
public string? NoEconomico { get; set; }
|
||||||
|
public decimal? Velocidad { get; set; }
|
||||||
|
public bool? Encendido { get; set; }
|
||||||
|
public DateTime? FechaTrama { get; set; }
|
||||||
|
public string? Punto { get; set; }
|
||||||
|
public double? Longitud { get; set; }
|
||||||
|
public double? Latitud { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ADS.Apis.Gps.Application.Units;
|
||||||
|
|
||||||
|
public sealed class UnitLastLocationResponse
|
||||||
|
{
|
||||||
|
public string? NoEconomico { get; set; }
|
||||||
|
public decimal? Velocidad { get; set; }
|
||||||
|
public bool? Encendido { get; set; }
|
||||||
|
public DateTime? FechaTrama { get; set; }
|
||||||
|
public string? Punto { get; set; }
|
||||||
|
public double? Longitud { get; set; }
|
||||||
|
public double? Latitud { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ADS.Apis.Gps.Application.Common.Crypto;
|
||||||
|
using ADS.Apis.Gps.Application.Contracts.Auth;
|
||||||
|
using ADS.Apis.Gps.Application.Dtos.Auth;
|
||||||
|
using ADS.Apis.Gps.Domain.Ports.Sicrep;
|
||||||
|
using ADS.Helpers.MultiTenant.Abstractions;
|
||||||
|
|
||||||
|
// AJUSTA el namespace real de IAdsTokenService
|
||||||
|
using ADS.Helpers.Auth.Abstractions;
|
||||||
|
using ADS.Helpers.MultiTenant.Context;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Application.UseCases.Auth;
|
||||||
|
|
||||||
|
public sealed class SicrepAuthService : ISicrepAuthService
|
||||||
|
{
|
||||||
|
private readonly ISicrepUserValidationRepository _repo;
|
||||||
|
private readonly ITenantContextAccessor _tenantAccessor;
|
||||||
|
private readonly IAdsTokenService _tokenService;
|
||||||
|
private readonly ILegacyPasswordEncryptor _passwordEncryptor;
|
||||||
|
|
||||||
|
public SicrepAuthService(
|
||||||
|
ISicrepUserValidationRepository repo,
|
||||||
|
ITenantContextAccessor tenantAccessor,
|
||||||
|
IAdsTokenService tokenService,
|
||||||
|
ILegacyPasswordEncryptor passwordEncryptor)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_tenantAccessor = tenantAccessor;
|
||||||
|
_tokenService = tokenService;
|
||||||
|
_passwordEncryptor = passwordEncryptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SicrepAuthResponse> CreateTokenAsync(SicrepAuthRequest request,
|
||||||
|
string? ip,
|
||||||
|
string? mac,
|
||||||
|
string? grupoEmpresa, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (request is null) throw new ArgumentNullException(nameof(request));
|
||||||
|
|
||||||
|
var tenant = _tenantAccessor.Current;
|
||||||
|
if (tenant is null)
|
||||||
|
throw new UnauthorizedAccessException("TenantContext not available. Ensure UseInternalTenantDb() is configured.");
|
||||||
|
|
||||||
|
var cveEmpresa = tenant.EmpresaId;
|
||||||
|
var cveCliente = tenant.ClienteId;
|
||||||
|
var tenantId = tenant.TenantId;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Usuario))
|
||||||
|
throw new ArgumentException("Usuario is required.", nameof(request.Usuario));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Contrasena))
|
||||||
|
throw new ArgumentException("Contrasena is required.", nameof(request.Contrasena));
|
||||||
|
|
||||||
|
// NOTA MD5:
|
||||||
|
// No puedo asegurar si el SP espera MD5 hex 32 o plaintext.
|
||||||
|
// Estrategia segura:
|
||||||
|
// - Si ya viene con 32 hex -> se manda tal cual.
|
||||||
|
// - Si no -> se manda MD5.
|
||||||
|
// Si tu sistema espera plaintext, cambia esta línea.
|
||||||
|
var contrasena32 = _passwordEncryptor.Encrypt(request.Contrasena);/*Md5Hex32.IsMd5Hex32(request.Contrasena)
|
||||||
|
? request.Contrasena
|
||||||
|
: Md5Hex32.ToMd5Hex32(request.Contrasena);*/
|
||||||
|
|
||||||
|
var result = await _repo.ValidateUserAsync(request.Usuario, contrasena32, cveEmpresa,ip,mac,grupoEmpresa, ct);
|
||||||
|
|
||||||
|
if (result is null || result.CveUsuario <= 0 || result.Acceso != 1)
|
||||||
|
throw new UnauthorizedAccessException("Invalid credentials or access denied.");
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new("CveUsuario", result.CveUsuario.ToString()),
|
||||||
|
new("CveEmpresa", cveEmpresa.ToString()),
|
||||||
|
new("CveCliente", cveCliente.ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.CveRol is not null)
|
||||||
|
claims.Add(new("CveRol", result.CveRol.Value.ToString()));
|
||||||
|
|
||||||
|
var token = await _tokenService.IssueFromClaimsAsync(claims, scope: null, ct);
|
||||||
|
|
||||||
|
return new SicrepAuthResponse
|
||||||
|
{
|
||||||
|
AccessToken = token.AccessToken
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
117
Apis/ADS.Apis.Gps/Controllers/Sicrep/SicrepAuthController.cs
Normal file
117
Apis/ADS.Apis.Gps/Controllers/Sicrep/SicrepAuthController.cs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ADS.Apis.Gps.Application.Contracts.Auth;
|
||||||
|
using ADS.Apis.Gps.Application.Dtos.Auth;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Controllers.Sicrep;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("gps/sicrep/auth")]
|
||||||
|
public sealed class SicrepAuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISicrepAuthService _service;
|
||||||
|
|
||||||
|
public SicrepAuthController(ISicrepAuthService service)
|
||||||
|
{
|
||||||
|
_service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("token")]
|
||||||
|
public async Task<ActionResult<SicrepAuthResponse>> CreateToken(
|
||||||
|
[FromBody] SicrepAuthRequest request,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ip = GetClientIp(HttpContext);
|
||||||
|
var mac = HttpContext.Request.Headers["X-Device-Mac"].ToString(); // si tu app lo manda
|
||||||
|
var grupoEmpresa = HttpContext.Request.Headers["X-GrupoEmpresa"].ToString();
|
||||||
|
var response = await _service.CreateTokenAsync(request,ip,mac,grupoEmpresa, ct);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
static string? GetClientIp(HttpContext http)
|
||||||
|
{
|
||||||
|
// 1) Si hay proxy, X-Forwarded-For trae una lista: "client, proxy1, proxy2"
|
||||||
|
if (http.Request.Headers.TryGetValue("X-Forwarded-For", out var xff))
|
||||||
|
{
|
||||||
|
var first = xff.ToString().Split(',')[0].Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(first)) return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) fallback
|
||||||
|
return http.Connection.RemoteIpAddress?.ToString();
|
||||||
|
}
|
||||||
|
// // ========= UNITS =========
|
||||||
|
|
||||||
|
// [HttpGet("units")]
|
||||||
|
// [ProducesResponseType(typeof(PagedResponse<UnitListItem>), StatusCodes.Status200OK)]
|
||||||
|
// public Task<PagedResponse<UnitListItem>> GetUnits(
|
||||||
|
// [FromQuery] string? search,
|
||||||
|
// [FromQuery] int page = 1,
|
||||||
|
// [FromQuery] int pageSize = 50,
|
||||||
|
// CancellationToken ct = default)
|
||||||
|
// => _svc.GetUnitsAsync(search, page, pageSize, ct);
|
||||||
|
|
||||||
|
// [HttpGet("units/{unitId:int}")]
|
||||||
|
// [ProducesResponseType(typeof(UnitDetail), StatusCodes.Status200OK)]
|
||||||
|
// [ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
// public async Task<ActionResult<UnitDetail>> GetUnit([FromRoute] int unitId, CancellationToken ct = default)
|
||||||
|
// {
|
||||||
|
// var dto = await _svc.GetUnitAsync(unitId, ct);
|
||||||
|
// return dto is null ? NotFound() : Ok(dto);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// [HttpGet("units/{unitId:int}/location")]
|
||||||
|
// [ProducesResponseType(typeof(UnitLocation), StatusCodes.Status200OK)]
|
||||||
|
// [ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
// public async Task<ActionResult<UnitLocation>> GetLastLocation([FromRoute] int unitId, CancellationToken ct = default)
|
||||||
|
// {
|
||||||
|
// var dto = await _svc.GetLastLocationAsync(unitId, ct);
|
||||||
|
// return dto is null ? NotFound() : Ok(dto);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// [HttpGet("units/{unitId:int}/positions")]
|
||||||
|
// [ProducesResponseType(typeof(PositionsResponse), StatusCodes.Status200OK)]
|
||||||
|
// public Task<PositionsResponse> GetPositions(
|
||||||
|
// [FromRoute] int unitId,
|
||||||
|
// [FromQuery] DateTime from,
|
||||||
|
// [FromQuery] DateTime to,
|
||||||
|
// [FromQuery] int limit = 5000,
|
||||||
|
// CancellationToken ct = default)
|
||||||
|
// => _svc.GetPositionsAsync(unitId, from, to, limit, ct);
|
||||||
|
|
||||||
|
// [HttpGet("units/{unitId:int}/events")]
|
||||||
|
// [ProducesResponseType(typeof(EventsResponse), StatusCodes.Status200OK)]
|
||||||
|
// public Task<EventsResponse> GetEvents(
|
||||||
|
// [FromRoute] int unitId,
|
||||||
|
// [FromQuery] DateTime from,
|
||||||
|
// [FromQuery] DateTime to,
|
||||||
|
// CancellationToken ct = default)
|
||||||
|
// => _svc.GetEventsAsync(unitId, from, to, ct);
|
||||||
|
|
||||||
|
// [HttpGet("units/{unitId:int}/status")]
|
||||||
|
// [ProducesResponseType(typeof(UnitStatus), StatusCodes.Status200OK)]
|
||||||
|
// [ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
// public async Task<ActionResult<UnitStatus>> GetStatus([FromRoute] int unitId, CancellationToken ct = default)
|
||||||
|
// {
|
||||||
|
// var dto = await _svc.GetStatusAsync(unitId, ct);
|
||||||
|
// return dto is null ? NotFound() : Ok(dto);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// [HttpPost("units/{unitId:int}/status")]
|
||||||
|
// [ProducesResponseType(typeof(UnitStatus), StatusCodes.Status200OK)]
|
||||||
|
// public Task<UnitStatus> SetStatus(
|
||||||
|
// [FromRoute] int unitId,
|
||||||
|
// [FromBody] UnitStatusRequest body,
|
||||||
|
// CancellationToken ct = default)
|
||||||
|
// => _svc.SetStatusAsync(unitId, body, ct);
|
||||||
|
|
||||||
|
// [HttpGet("units/{unitId:int}/health")]
|
||||||
|
// [ProducesResponseType(typeof(UnitHealth), StatusCodes.Status200OK)]
|
||||||
|
// [ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
// public async Task<ActionResult<UnitHealth>> GetHealth([FromRoute] int unitId, CancellationToken ct = default)
|
||||||
|
// {
|
||||||
|
// var dto = await _svc.GetHealthAsync(unitId, ct);
|
||||||
|
// return dto is null ? NotFound() : Ok(dto);
|
||||||
|
// }
|
||||||
|
}
|
||||||
57
Apis/ADS.Apis.Gps/Controllers/UnitsController.cs
Normal file
57
Apis/ADS.Apis.Gps/Controllers/UnitsController.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using ADS.Apis.Gps.Application.Units;
|
||||||
|
using ADS.Helpers.Middleware.InternalUser;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("units")]
|
||||||
|
public sealed class UnitsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IUnitLocationQueryService _unitLocationQueryService;
|
||||||
|
private readonly IInternalUserContextAccessor _internalUserContextAccessor;
|
||||||
|
|
||||||
|
public UnitsController(
|
||||||
|
IUnitLocationQueryService unitLocationQueryService,
|
||||||
|
IInternalUserContextAccessor internalUserContextAccessor)
|
||||||
|
{
|
||||||
|
_unitLocationQueryService = unitLocationQueryService;
|
||||||
|
_internalUserContextAccessor = internalUserContextAccessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("location/{noEconomico}")]
|
||||||
|
public async Task<IActionResult> GetLastLocationByNoEconomico(
|
||||||
|
[FromRoute] string noEconomico,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cveUsuario = _internalUserContextAccessor.Current?.CveUser;
|
||||||
|
|
||||||
|
if (cveUsuario is null || cveUsuario <= 0)
|
||||||
|
return Unauthorized("No se encontró el usuario interno.");
|
||||||
|
|
||||||
|
var result = await _unitLocationQueryService
|
||||||
|
.GetLastLocationByNoEconomicoAsync(cveUsuario.Value, noEconomico, ct);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("client/{cveCliente}")]
|
||||||
|
public async Task<IActionResult> GetLocationsByClient(
|
||||||
|
[FromRoute] int cveCliente,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cveUsuario = _internalUserContextAccessor.Current?.CveUser;
|
||||||
|
|
||||||
|
if (cveUsuario is null || cveUsuario <= 0)
|
||||||
|
return Unauthorized("No se encontró el usuario interno.");
|
||||||
|
|
||||||
|
var result = await _unitLocationQueryService
|
||||||
|
.GetLocationsByClientAsync(cveUsuario.Value, cveCliente, ct);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ADS.Apis.Gps.Domain.Models.Sicrep;
|
||||||
|
|
||||||
|
public sealed class SicrepUserValidationResult
|
||||||
|
{
|
||||||
|
public int CveUsuario { get; init; }
|
||||||
|
public int Acceso { get; init; }
|
||||||
|
|
||||||
|
public int? CveRol { get; init; }
|
||||||
|
|
||||||
|
// Si tu SP regresa más campos, agrégalos aquí (p.ej. nombre, email, etc.)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ADS.Apis.Gps.Domain.Models.Sicrep;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Domain.Ports.Sicrep;
|
||||||
|
|
||||||
|
public interface ISicrepUserValidationRepository
|
||||||
|
{
|
||||||
|
Task<SicrepUserValidationResult?> ValidateUserAsync(
|
||||||
|
string usuario,
|
||||||
|
string contrasena32,
|
||||||
|
int cveEmpresa,
|
||||||
|
string? ip,
|
||||||
|
string? mac,
|
||||||
|
string? grupoEmpresa,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ADS.Apis.Gps.Infrastructure.Crypto;
|
||||||
|
|
||||||
|
public sealed class LegacyCryptoOptions
|
||||||
|
{
|
||||||
|
public string Mode { get; set; } = "AES"; // "DES" o "AES"
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
public string IV { get; set; } = ""; // Base64
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using ADS.Apis.Gps.Application.Common.Crypto;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Infrastructure.Crypto;
|
||||||
|
|
||||||
|
public sealed class LegacyPasswordEncryptor : ILegacyPasswordEncryptor
|
||||||
|
{
|
||||||
|
private readonly LegacyCryptoOptions _opt;
|
||||||
|
|
||||||
|
public LegacyPasswordEncryptor(IOptions<LegacyCryptoOptions> opt)
|
||||||
|
=> _opt = opt.Value;
|
||||||
|
|
||||||
|
public string Encrypt(string plainText)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(plainText)) return string.Empty;
|
||||||
|
|
||||||
|
var mode = (_opt.Mode ?? "AES").Trim().ToUpperInvariant();
|
||||||
|
var iv = Convert.FromBase64String(_opt.IV ?? "");
|
||||||
|
|
||||||
|
return mode switch
|
||||||
|
{
|
||||||
|
"DES" => EncryptDesBase64(plainText, _opt.Key, iv),
|
||||||
|
"AES" => EncryptAesBase64(plainText, _opt.Key, iv),
|
||||||
|
_ => throw new InvalidOperationException($"Unsupported LegacyCrypto.Mode: {_opt.Mode}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EncryptDesBase64(string plainText, string keySource, byte[] iv8)
|
||||||
|
{
|
||||||
|
if (iv8.Length != 8) throw new ArgumentException("DES IV must be 8 bytes.", nameof(iv8));
|
||||||
|
|
||||||
|
var key8 = Encoding.UTF8.GetBytes((keySource ?? string.Empty).PadRight(8).Substring(0, 8));
|
||||||
|
|
||||||
|
using var des = new DESCryptoServiceProvider
|
||||||
|
{
|
||||||
|
Mode = CipherMode.CBC,
|
||||||
|
Padding = PaddingMode.PKCS7,
|
||||||
|
Key = key8,
|
||||||
|
IV = iv8
|
||||||
|
};
|
||||||
|
|
||||||
|
var input = Encoding.UTF8.GetBytes(plainText);
|
||||||
|
using var encryptor = des.CreateEncryptor(des.Key, des.IV);
|
||||||
|
var cipher = encryptor.TransformFinalBlock(input, 0, input.Length);
|
||||||
|
|
||||||
|
return Convert.ToBase64String(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EncryptAesBase64(string plainText, string keySource, byte[] iv16)
|
||||||
|
{
|
||||||
|
if (iv16.Length != 16) throw new ArgumentException("AES IV must be 16 bytes.", nameof(iv16));
|
||||||
|
|
||||||
|
var key32 = Encoding.UTF8.GetBytes((keySource ?? string.Empty).PadRight(32).Substring(0, 32));
|
||||||
|
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Mode = CipherMode.CBC;
|
||||||
|
aes.Padding = PaddingMode.PKCS7;
|
||||||
|
aes.Key = key32;
|
||||||
|
aes.IV = iv16;
|
||||||
|
|
||||||
|
var input = Encoding.UTF8.GetBytes(plainText);
|
||||||
|
using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
|
||||||
|
var cipher = encryptor.TransformFinalBlock(input, 0, input.Length);
|
||||||
|
|
||||||
|
return Convert.ToBase64String(cipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ADS.Apis.Gps.Domain.Models.Sicrep;
|
||||||
|
using ADS.Apis.Gps.Domain.Ports.Sicrep;
|
||||||
|
using ADS.Helpers.Data.Abstractions;
|
||||||
|
using ADS.Helpers.MultiTenant.Abstractions;
|
||||||
|
using ADS.Helpers.MultiTenant.Context;
|
||||||
|
using Dapper;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Infrastructure.Data.Dapper;
|
||||||
|
|
||||||
|
public sealed class DapperSicrepUserValidationRepository : ISicrepUserValidationRepository
|
||||||
|
{
|
||||||
|
private readonly ITenantDbConnectionFactory _connFactory;
|
||||||
|
private readonly ITenantContextAccessor _tenantAccessor;
|
||||||
|
|
||||||
|
public DapperSicrepUserValidationRepository(
|
||||||
|
ITenantDbConnectionFactory connFactory,
|
||||||
|
ITenantContextAccessor tenantAccessor)
|
||||||
|
{
|
||||||
|
_connFactory = connFactory;
|
||||||
|
_tenantAccessor = tenantAccessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SicrepUserValidationResult?> ValidateUserAsync(
|
||||||
|
string usuario,
|
||||||
|
string contrasena32,
|
||||||
|
int cveEmpresa,
|
||||||
|
string? ip,
|
||||||
|
string? mac,
|
||||||
|
string? grupoEmpresa,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var tenant = _tenantAccessor.Current
|
||||||
|
?? throw new UnauthorizedAccessException("TenantContext not available.");
|
||||||
|
|
||||||
|
// USAMOS TU MÉTODO REAL
|
||||||
|
using var conn = _connFactory.Create();//tenant.Database.ConnectionString);
|
||||||
|
|
||||||
|
var p = new DynamicParameters();
|
||||||
|
p.Add("@Usuario", usuario, DbType.String, size: 32);
|
||||||
|
p.Add("@Contraseña", contrasena32, DbType.String, size: 32);
|
||||||
|
p.Add("@CveEmpresa", cveEmpresa, DbType.Int32);
|
||||||
|
p.Add("@IP", ip ?? string.Empty, DbType.String, size: 20);
|
||||||
|
p.Add("@MAC", mac ?? string.Empty, DbType.String, size: 50);
|
||||||
|
p.Add("@GrupoEmpresa", grupoEmpresa ?? string.Empty, DbType.String, size: 100);
|
||||||
|
const string sp = "[dbo].[PA_Sis_UsuarioValidacionGet]";
|
||||||
|
|
||||||
|
var result = await conn.QueryFirstOrDefaultAsync<SicrepUserValidationResult>(
|
||||||
|
sp,
|
||||||
|
p,
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using ADS.Apis.Gps.Application.Abstractions.Security;
|
||||||
|
using ADS.Apis.Gps.Application.Common.Crypto;
|
||||||
|
using ADS.Apis.Gps.Application.Contracts.Auth;
|
||||||
|
using ADS.Apis.Gps.Application.UseCases.Auth;
|
||||||
|
using ADS.Apis.Gps.Domain.Ports.Sicrep;
|
||||||
|
using ADS.Apis.Gps.Infrastructure.Crypto;
|
||||||
|
using ADS.Apis.Gps.Infrastructure.Data.Dapper;
|
||||||
|
using ADS.Apis.Gps.Infrastructure.Security;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Infrastructure.DependencyInjection;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddGpsSicrepAuth(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IAdsUserCredentialValidator, AdsUserCredentialValidator>();
|
||||||
|
services.AddScoped<IAdsClientCredentialValidator, AdsClientCredentialValidator>();
|
||||||
|
// Domain Ports -> Infrastructure
|
||||||
|
services.AddScoped<ISicrepUserValidationRepository, DapperSicrepUserValidationRepository>();
|
||||||
|
|
||||||
|
// Application
|
||||||
|
services.AddScoped<ISicrepAuthService, SicrepAuthService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
public static IServiceCollection AddGpsInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.Configure<LegacyCryptoOptions>(configuration.GetSection("LegacyCrypto"));
|
||||||
|
services.AddSingleton<ILegacyPasswordEncryptor, LegacyPasswordEncryptor>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Dapper;
|
||||||
|
using ADS.Apis.Gps.Application.Abstractions.Security;
|
||||||
|
using ADS.Helpers.Data.Abstractions;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Infrastructure.Security;
|
||||||
|
|
||||||
|
public sealed class AdsClientCredentialValidator : IAdsClientCredentialValidator
|
||||||
|
{
|
||||||
|
private readonly ITenantDbConnectionFactory _db;
|
||||||
|
|
||||||
|
public AdsClientCredentialValidator(ITenantDbConnectionFactory db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdsClientCredentialResult> ValidateAsync(string clientId, string clientSecret, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// using var conn = await _db.OpenAsync(ct);
|
||||||
|
|
||||||
|
// var p = new DynamicParameters();
|
||||||
|
// p.Add("@ClientId", clientId, DbType.String);
|
||||||
|
// p.Add("@ClientSecret", clientSecret, DbType.String);
|
||||||
|
|
||||||
|
// var row = await conn.QueryFirstOrDefaultAsync<ClientRow>(
|
||||||
|
// sql: "PA_Sicrep_ValidateClient",
|
||||||
|
// param: p,
|
||||||
|
// commandType: CommandType.StoredProcedure
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (row is null || row.IsValid == 0)
|
||||||
|
// return new AdsClientCredentialResult(false, null, null, null);
|
||||||
|
|
||||||
|
// return new AdsClientCredentialResult(true, row.ClientId, row.ClientKey, row.ClientName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ClientRow
|
||||||
|
{
|
||||||
|
public int IsValid { get; init; }
|
||||||
|
public int ClientId { get; init; }
|
||||||
|
public string ClientKey { get; init; } = string.Empty;
|
||||||
|
public string ClientName { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Dapper;
|
||||||
|
using ADS.Apis.Gps.Application.Abstractions.Security;
|
||||||
|
using ADS.Helpers.Data.Abstractions;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Infrastructure.Security;
|
||||||
|
|
||||||
|
public sealed class AdsUserCredentialValidator : IAdsUserCredentialValidator
|
||||||
|
{
|
||||||
|
private readonly ITenantDbConnectionFactory _db;
|
||||||
|
|
||||||
|
public AdsUserCredentialValidator(ITenantDbConnectionFactory db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdsUserCredentialResult> ValidateAsync(string username, string password, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// using var conn = await _db.OpenAsync(ct);
|
||||||
|
|
||||||
|
// // EJEMPLO: si validas con SP
|
||||||
|
// // Cambia el nombre del SP y los nombres de parámetros a tus reales.
|
||||||
|
// var p = new DynamicParameters();
|
||||||
|
// p.Add("@Usuario", username, DbType.String);
|
||||||
|
// p.Add("@Password", password, DbType.String);
|
||||||
|
|
||||||
|
// var row = await conn.QueryFirstOrDefaultAsync<UserRow>(
|
||||||
|
// sql: "PA_Sicrep_ValidateUser",
|
||||||
|
// param: p,
|
||||||
|
// commandType: CommandType.StoredProcedure
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (row is null || row.IsValid == 0)
|
||||||
|
// return new AdsUserCredentialResult(false, null, null, null);
|
||||||
|
|
||||||
|
// return new AdsUserCredentialResult(true, row.UserId, row.Username, row.DisplayName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class UserRow
|
||||||
|
{
|
||||||
|
public int IsValid { get; init; }
|
||||||
|
public int UserId { get; init; }
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Data;
|
||||||
|
using ADS.Apis.Gps.Application.Units;
|
||||||
|
using ADS.Helpers.Data;
|
||||||
|
using ADS.Helpers.Data.Abstractions;
|
||||||
|
using Dapper;
|
||||||
|
|
||||||
|
namespace ADS.Apis.Gps.Infrastructure.Units;
|
||||||
|
|
||||||
|
public sealed class UnitLocationQueryService : IUnitLocationQueryService
|
||||||
|
{
|
||||||
|
private readonly ITenantDbConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
public UnitLocationQueryService(ITenantDbConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UnitLastLocationResponse?> GetLastLocationByNoEconomicoAsync(
|
||||||
|
int cveUsuario,
|
||||||
|
string noEconomico,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var connection = _connectionFactory.Create();
|
||||||
|
|
||||||
|
var command = new CommandDefinition(
|
||||||
|
commandText: "dbo.PA_GPS_DispositovoUbicacionByNoEconomicoGet",
|
||||||
|
parameters: new
|
||||||
|
{
|
||||||
|
CveUsuario = cveUsuario,
|
||||||
|
NoEconomico = noEconomico
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure,
|
||||||
|
cancellationToken: ct
|
||||||
|
);
|
||||||
|
|
||||||
|
return await connection.QueryFirstOrDefaultAsync<UnitLastLocationResponse>(command);
|
||||||
|
}
|
||||||
|
public async Task<IReadOnlyList<UnitClientLocationResponse>> GetLocationsByClientAsync(
|
||||||
|
int cveUsuario,
|
||||||
|
int cveCliente,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var connection = _connectionFactory.Create();
|
||||||
|
|
||||||
|
var command = new CommandDefinition(
|
||||||
|
commandText: "dbo.PA_GPS_DispositovoUbicacionPorClienteGet",
|
||||||
|
parameters: new
|
||||||
|
{
|
||||||
|
CveUsuario = cveUsuario,
|
||||||
|
CveCliente = cveCliente
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure,
|
||||||
|
cancellationToken: ct
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await connection.QueryAsync<UnitClientLocationResponse>(command);
|
||||||
|
|
||||||
|
return result.AsList();
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Apis/ADS.Apis.Gps/Program.cs
Normal file
49
Apis/ADS.Apis.Gps/Program.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using ADS.Helpers.Data.Extensions;
|
||||||
|
using ADS.Helpers.MultiTenant.InternalRouting;
|
||||||
|
using ADS.Helpers.Auth.Extensions;
|
||||||
|
using ADS.Helpers.Middleware.ADSExtensions;
|
||||||
|
using ADS.Helpers.Middleware.Extensions;
|
||||||
|
using ADS.Helpers.Middleware.InternalUser;
|
||||||
|
using ADS.Apis.Gps.Application.Units;
|
||||||
|
using ADS.Apis.Gps.Infrastructure.Units;
|
||||||
|
// using Microsoft.OpenApi.Models;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
builder.Services.AddAdsApiResponse(builder.Configuration);
|
||||||
|
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
// builder.Services.AddAdsAuthentication(builder.Configuration);
|
||||||
|
// builder.Services.AddAdsAuthorization();
|
||||||
|
|
||||||
|
// ✅ 1) Middleware interno: valida HMAC + resuelve tenant (debe llenar ITenantContextAccessor.Current)
|
||||||
|
builder.Services.AddInternalTenantDb(builder.Configuration);
|
||||||
|
builder.Services.AddScoped<IInternalUserContextAccessor, InternalUserContextAccessor>();
|
||||||
|
builder.Services.AddScoped<IUnitLocationQueryService, UnitLocationQueryService>();
|
||||||
|
|
||||||
|
// ✅ 2) Factory transversal (usa tenant.Database.ConnectionString)
|
||||||
|
builder.Services.AddAdsTenantDb();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
app.UseRouting();
|
||||||
|
|
||||||
|
// primero exception middleware (para atrapar todo)
|
||||||
|
app.UseAdsApiResponse();
|
||||||
|
// ✅ HMAC + set TenantContext
|
||||||
|
app.UseInternalTenantDb();
|
||||||
|
app.UseAdsInternalUser();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
app.Run();
|
||||||
23
Apis/ADS.Apis.Gps/Properties/launchSettings.json
Normal file
23
Apis/ADS.Apis.Gps/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5023",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7134;http://localhost:5023",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
Apis/ADS.Apis.Gps/appsettings.json
Normal file
27
Apis/ADS.Apis.Gps/appsettings.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"InternalRouting": {
|
||||||
|
"SharedSecret": "MISMO_SECRETOJCAUDIADS_DEL_HOST",
|
||||||
|
"MaxSkewSeconds": 120
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Issuer": "ADS",
|
||||||
|
"Audience": "ADS",
|
||||||
|
"SigningKey": "SUPER_JCau127*953SECRET_KEY_CHANGE_THIS",
|
||||||
|
"AccessTokenMinutes": 60
|
||||||
|
},
|
||||||
|
"LegacyCrypto": {
|
||||||
|
"Mode": "DES",
|
||||||
|
"Key": "$Ap7+5&z",
|
||||||
|
"IV": "1234567890ABCDEF"
|
||||||
|
},
|
||||||
|
"LoggingOptions": {
|
||||||
|
"LogFolder": "C:\\Logs\\ADS\\Gps"
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
30
Apis/ADS.Apis.Gps/readmegpt
Normal file
30
Apis/ADS.Apis.Gps/readmegpt
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
Quiero rehacer desde cero la API ADS.Apis.Gps con orden limpio (Domain / Application / Infrastructure / Controllers) y multi-tenant por headers internos desde el Host.
|
||||||
|
|
||||||
|
Contexto:
|
||||||
|
- El Host (gateway) resuelve tenant y manda headers internos firmados (InternalRouting).
|
||||||
|
- En el micro GPS ya uso UseInternalTenantDb() para validar firma y poblar TenantContext.
|
||||||
|
- TenantContext tiene: EmpresaId, ClienteId, TenantId (string), Culture, Theme, Database { Provider, ConnectionString }, CustomSettings.
|
||||||
|
- GPS NO debe leer appsettings para tenants ni duplicar config del Host. NO usar ITenantStore en GPS.
|
||||||
|
- La conexión a BD siempre sale de TenantContext.Database.ConnectionString y se abre vía ITenantDbConnectionFactory (ADS.Helpers.Data).
|
||||||
|
|
||||||
|
Objetivo inmediato:
|
||||||
|
1) Definir la estructura de carpetas y proyectos dentro de ADS.Apis.Gps:
|
||||||
|
- Domain: modelos/puertos (interfaces) sin SQL ni Dapper.
|
||||||
|
- Application: casos de uso/servicios y orquestación.
|
||||||
|
- Infrastructure: Dapper/SQL repos/stores.
|
||||||
|
- Controllers: endpoints.
|
||||||
|
2) Implementar autenticación Sicrep en GPS:
|
||||||
|
- Endpoint POST /gps/sicrep/auth/token
|
||||||
|
- Antes de generar JWT, ejecutar el SP [dbo].[PA_Sis_UsuarioValidacionGet] con @Usuario, @Contraseña (varchar(32)), @CveEmpresa (del tenant), y validar que CveUsuario > 0 y Acceso = 1.
|
||||||
|
- Luego emitir JWT usando ADS.Helpers.Auth (IAdsTokenService) con TenantId string y claims (CveUsuario, CveEmpresa, CveCliente, CveRol, etc.)
|
||||||
|
3) Entregarme:
|
||||||
|
- Árbol final de carpetas.
|
||||||
|
- Código completo de: puertos (interfaces), modelos domain, repos Infrastructure (Dapper + SP), servicio Application (caso de uso), controller, y registros DI en Program.cs.
|
||||||
|
- NOTA: No uses GetConnectionString("...") ni appsettings para la DB del micro.
|
||||||
|
|
||||||
|
Datos extra:
|
||||||
|
- Confirmar si la contraseña en BD ya viene en MD5 hex 32 o si debo hashear antes de llamar al SP. (Si no sabes, deja helper para MD5 y comenta dónde se decide).
|
||||||
|
IMPORTANTE: No inventes métodos que no existan; si necesitas agregar algo a ADS.Helpers.Auth o ADS.Helpers.MultiTenant/Helpers.Data, dilo explícitamente y dame el código exacto del archivo a modificar.
|
||||||
|
pasame un script para la generacion de archivos y carpetas
|
||||||
|
tampoco te inventes rutas nuevas las apis las estoy guardando en la carpeta de apis
|
||||||
|
la ruta del proyecto debe ser exatamente la misma para que funciione con ADS.Apis.slnx
|
||||||
11
Apis/ADS.Apis.slnx
Normal file
11
Apis/ADS.Apis.slnx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="../Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj" />
|
||||||
|
<Project Path="../Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj" />
|
||||||
|
<Project Path="../Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj" />
|
||||||
|
<Project Path="../Helpers/ADS.Helpers.Localization/ADS.Helpers.Localization.csproj" />
|
||||||
|
<Project Path="../Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj" />
|
||||||
|
<Project Path="../Helpers/ADS.Helpers.MultiTenant/ADS.Helpers.MultiTenant.csproj" />
|
||||||
|
<Project Path="../Helpers/ADS.Helpers.NotifyCenter/ADS.Helpers.NotifyCenter.csproj" />
|
||||||
|
<Project Path="ADS.Apis.Gps/ADS.Apis.Gps.csproj" />
|
||||||
|
<Project Path="ADS.Host/ADS.Host.csproj" />
|
||||||
|
</Solution>
|
||||||
27
Apis/ADS.Host/ADS.Host.csproj
Normal file
27
Apis/ADS.Host/ADS.Host.csproj
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.3" />
|
||||||
|
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
|
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Helpers\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Helpers\ADS.Helpers.Middleware\ADS.Helpers.Middleware.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Helpers\ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Helpers\ADS.Helpers.NotifyCenter\ADS.Helpers.NotifyCenter.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Helpers\ADS.Helpers.Localization\ADS.Helpers.Localization.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Helpers\ADS.Helpers.Auth\ADS.Helpers.Auth.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
Apis/ADS.Host/ADS.Host.http
Normal file
6
Apis/ADS.Host/ADS.Host.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@ADS.Host_HostAddress = http://localhost:5030
|
||||||
|
|
||||||
|
GET {{ADS.Host_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
142
Apis/ADS.Host/Auth/HostAdsUserCredentialValidator .cs
Normal file
142
Apis/ADS.Host/Auth/HostAdsUserCredentialValidator .cs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
using ADS.Helpers.Auth.Abstractions;
|
||||||
|
using ADS.Helpers.MultiTenant.Abstractions;
|
||||||
|
using ADS.Helpers.MultiTenant.Context;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Npgsql;
|
||||||
|
using System.Data;
|
||||||
|
using System.Text;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace ADS.Host.Auth;
|
||||||
|
|
||||||
|
public class HostAdsUserCredentialValidator : IAdsUserCredentialValidator
|
||||||
|
{
|
||||||
|
private readonly ITenantContextAccessor _tenantContextAccessor;
|
||||||
|
|
||||||
|
public HostAdsUserCredentialValidator(ITenantContextAccessor tenantContextAccessor)
|
||||||
|
{
|
||||||
|
_tenantContextAccessor = tenantContextAccessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdsUserValidationResult> ValidateAsync(
|
||||||
|
string userName,
|
||||||
|
string password,
|
||||||
|
string? tenantId,
|
||||||
|
string? scope,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var tenant = _tenantContextAccessor.Current;
|
||||||
|
|
||||||
|
if (tenant == null)
|
||||||
|
{
|
||||||
|
return new AdsUserValidationResult
|
||||||
|
{
|
||||||
|
IsValid = false,
|
||||||
|
Error = "TenantContext no está cargado."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenant.Database == null ||
|
||||||
|
string.IsNullOrWhiteSpace(tenant.Database.ConnectionString))
|
||||||
|
{
|
||||||
|
return new AdsUserValidationResult
|
||||||
|
{
|
||||||
|
IsValid = false,
|
||||||
|
Error = "El tenant no tiene conexión configurada."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
using var conn = CreateConnection(
|
||||||
|
tenant.Database.Provider,
|
||||||
|
tenant.Database.ConnectionString);
|
||||||
|
|
||||||
|
var result = await conn.QueryFirstOrDefaultAsync<UsuarioLoginDbResult>(
|
||||||
|
"dbo.PA_Sis_UsuariosGet",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
CveUsuario = -1,
|
||||||
|
CveRol = -1,
|
||||||
|
CveUsuarioControlado = -1,
|
||||||
|
Usuario = userName,
|
||||||
|
Contraseña = EncriptaDES(password),
|
||||||
|
EsLogIn = true,
|
||||||
|
CvePlataforma = -1,
|
||||||
|
CveEmpresa = -1,
|
||||||
|
IP = "",
|
||||||
|
MAC = "",
|
||||||
|
GrupoEmpresa = "",
|
||||||
|
Estatus = -1
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null || result.CveUsuario <= 0)
|
||||||
|
{
|
||||||
|
return new AdsUserValidationResult
|
||||||
|
{
|
||||||
|
IsValid = false,
|
||||||
|
Error = "Usuario o contraseña inválidos."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AdsUserValidationResult
|
||||||
|
{
|
||||||
|
IsValid = true,
|
||||||
|
UserName=userName,
|
||||||
|
CveUser = result.CveUsuario,
|
||||||
|
CveRole = result.CveRol,
|
||||||
|
CveEmpresa = result.CveEmpresa,
|
||||||
|
CveCliente = result.CveCliente,
|
||||||
|
TenantId = tenant.TenantId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private static string EncriptaDES(string texto)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(texto))
|
||||||
|
return "";
|
||||||
|
|
||||||
|
byte[] key = Encoding.UTF8.GetBytes("$Ap7+5&z");
|
||||||
|
|
||||||
|
byte[] iv = new byte[]
|
||||||
|
{
|
||||||
|
0x12, 0x34, 0x56, 0x78,
|
||||||
|
0x90, 0xAB, 0xCD, 0xEF
|
||||||
|
};
|
||||||
|
|
||||||
|
using var des = DES.Create();
|
||||||
|
des.Key = key;
|
||||||
|
des.IV = iv;
|
||||||
|
des.Mode = CipherMode.CBC;
|
||||||
|
des.Padding = PaddingMode.PKCS7;
|
||||||
|
|
||||||
|
byte[] input = Encoding.UTF8.GetBytes(texto);
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var cs = new CryptoStream(ms, des.CreateEncryptor(), CryptoStreamMode.Write);
|
||||||
|
|
||||||
|
cs.Write(input, 0, input.Length);
|
||||||
|
cs.FlushFinalBlock();
|
||||||
|
|
||||||
|
return Convert.ToBase64String(ms.ToArray());
|
||||||
|
}
|
||||||
|
private static IDbConnection CreateConnection(string provider, string connectionString)
|
||||||
|
{
|
||||||
|
return provider?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"sqlserver" => new SqlConnection(connectionString),
|
||||||
|
"mssql" => new SqlConnection(connectionString),
|
||||||
|
"postgres" => new NpgsqlConnection(connectionString),
|
||||||
|
"postgresql" => new NpgsqlConnection(connectionString),
|
||||||
|
_ => throw new NotSupportedException($"Proveedor de base de datos no soportado: {provider}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private class UsuarioLoginDbResult
|
||||||
|
{
|
||||||
|
public int CveUsuario { get; set; }
|
||||||
|
public int CveRol { get; set; }
|
||||||
|
public int CveEmpresa { get; set; }
|
||||||
|
public int CveCliente { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Apis/ADS.Host/Auth/StubAdsClientCredentialValidator.cs
Normal file
22
Apis/ADS.Host/Auth/StubAdsClientCredentialValidator.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using ADS.Helpers.Auth.Abstractions;
|
||||||
|
|
||||||
|
namespace ADS.Host.Auth;
|
||||||
|
|
||||||
|
public sealed class StubAdsClientCredentialValidator : IAdsClientCredentialValidator
|
||||||
|
{
|
||||||
|
public Task<AdsClientValidationResult> ValidateAsync(
|
||||||
|
string clientId,
|
||||||
|
string clientSecret,
|
||||||
|
string? tenantId,
|
||||||
|
string? scope,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new AdsClientValidationResult
|
||||||
|
{
|
||||||
|
IsValid = false,
|
||||||
|
ClientId = clientId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Error = "IAdsClientCredentialValidator not configured in ADS.Host."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Apis/ADS.Host/Auth/StubAdsUserCredentialValidator.cs
Normal file
20
Apis/ADS.Host/Auth/StubAdsUserCredentialValidator.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using ADS.Helpers.Auth.Abstractions;
|
||||||
|
|
||||||
|
namespace ADS.Host.Auth;
|
||||||
|
|
||||||
|
public sealed class StubAdsUserCredentialValidator : IAdsUserCredentialValidator
|
||||||
|
{
|
||||||
|
public Task<AdsUserValidationResult> ValidateAsync(
|
||||||
|
string userName,
|
||||||
|
string password,
|
||||||
|
string? tenantId,
|
||||||
|
string? scope,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new AdsUserValidationResult
|
||||||
|
{
|
||||||
|
IsValid = false,
|
||||||
|
Error = "IAdsUserCredentialValidator not configured in ADS.Host."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
29
Apis/ADS.Host/Controllers/AuthController.cs
Normal file
29
Apis/ADS.Host/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using ADS.Helpers.Auth.Abstractions;
|
||||||
|
using ADS.Helpers.Auth.Token;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("auth")]
|
||||||
|
public class AuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAdsTokenService _tokenService;
|
||||||
|
|
||||||
|
public AuthController(
|
||||||
|
IAdsTokenService tokenService )
|
||||||
|
{
|
||||||
|
_tokenService = tokenService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("token")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> Token(
|
||||||
|
[FromBody] AdsTokenRequest request,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
|
||||||
|
var token = await _tokenService.IssueAsync(request,ct);
|
||||||
|
|
||||||
|
return Ok(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
Apis/ADS.Host/Controllers/WhoAmIController.cs
Normal file
36
Apis/ADS.Host/Controllers/WhoAmIController.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using ADS.Helpers.Auth.Abstractions;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ADS.Host.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public sealed class WhoAmIController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAdsPrincipalAccessor _principal;
|
||||||
|
|
||||||
|
public WhoAmIController(IAdsPrincipalAccessor principal)
|
||||||
|
{
|
||||||
|
_principal = principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
|
public IActionResult Get()
|
||||||
|
{
|
||||||
|
var me = _principal.Current;
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
me.CveUser,
|
||||||
|
me.TenantId,
|
||||||
|
me.UserName,
|
||||||
|
me.CveRole,
|
||||||
|
me.CveEmpresa,
|
||||||
|
me.CveCliente,
|
||||||
|
me.CveModulos,
|
||||||
|
me.Permissions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
125
Apis/ADS.Host/Program.cs
Normal file
125
Apis/ADS.Host/Program.cs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
using ADS.Helpers.Auth.Abstractions;
|
||||||
|
using ADS.Helpers.Auth.Extensions;
|
||||||
|
using ADS.Helpers.Middleware.Extensions;
|
||||||
|
using ADS.Helpers.MultiTenant.InternalRouting;
|
||||||
|
using ADS.Host.Auth;
|
||||||
|
using Yarp.ReverseProxy.Transforms;
|
||||||
|
using ADS.Helpers.Auth.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
builder.Services .AddReverseProxy()
|
||||||
|
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||||||
|
.AddTransforms(builderContext =>
|
||||||
|
{
|
||||||
|
builderContext.AddRequestTransform(async transformContext =>
|
||||||
|
{
|
||||||
|
var http = transformContext.HttpContext;
|
||||||
|
|
||||||
|
// Solo para rutas /gps (por si luego agregas más clusters)
|
||||||
|
if (!http.Request.Path.StartsWithSegments("/gps"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var tenantAccessor = http.RequestServices.GetRequiredService<ADS.Helpers.MultiTenant.Context.ITenantContextAccessor>();
|
||||||
|
var writer = http.RequestServices.GetRequiredService<ADS.Helpers.MultiTenant.InternalRouting.IInternalTenantDbHeaderWriter>();
|
||||||
|
|
||||||
|
var tenant = tenantAccessor.Current;
|
||||||
|
if (tenant is null)
|
||||||
|
return; // o lanza 401/400 si quieres forzarlo
|
||||||
|
|
||||||
|
var conn = tenant.Database.ConnectionString;
|
||||||
|
if (string.IsNullOrWhiteSpace(conn))
|
||||||
|
return; // igual: mejor forzar error si lo deseas
|
||||||
|
|
||||||
|
// Agregar headers internos firmados a la request proxyeada
|
||||||
|
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
writer.AddInternalHeaders(headers, tenant);
|
||||||
|
|
||||||
|
{
|
||||||
|
var cveUser = http.User.FindFirst(AdsClaimTypes.CveUser)?.Value;
|
||||||
|
var cveEmpresa = http.User.FindFirst(AdsClaimTypes.CveEmpresa)?.Value;
|
||||||
|
var cveCliente = http.User.FindFirst(AdsClaimTypes.CveCliente)?.Value;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(cveUser) &&
|
||||||
|
!string.IsNullOrWhiteSpace(cveEmpresa) &&
|
||||||
|
!string.IsNullOrWhiteSpace(cveCliente))
|
||||||
|
{
|
||||||
|
var tsUser = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||||
|
var secret = builderContext.Services
|
||||||
|
.GetRequiredService<IConfiguration>()["InternalRouting:SharedSecret"];
|
||||||
|
|
||||||
|
var raw = $"{cveUser}|{cveEmpresa}|{cveCliente}|{tsUser}";
|
||||||
|
|
||||||
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret!));
|
||||||
|
var signature = Convert.ToBase64String(
|
||||||
|
hmac.ComputeHash(Encoding.UTF8.GetBytes(raw)));
|
||||||
|
|
||||||
|
headers[InternalHeaders.CveUser] = cveUser;
|
||||||
|
headers[InternalHeaders.CveEmpresa] = cveEmpresa;
|
||||||
|
headers[InternalHeaders.CveCliente] = cveCliente;
|
||||||
|
headers[InternalHeaders.User_Timestamp] = tsUser;
|
||||||
|
headers[InternalHeaders.User_Signature] = signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
foreach (var kv in headers)
|
||||||
|
{
|
||||||
|
transformContext.ProxyRequest.Headers.Remove(kv.Key);
|
||||||
|
transformContext.ProxyRequest.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
builder.Services.AddAdsAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddAdsAuthorization();
|
||||||
|
|
||||||
|
// Tenant + Middlewares (ajustar a tus extensiones reales)
|
||||||
|
builder.Services.AddAdsMiddlewares(builder.Configuration);
|
||||||
|
builder.Services.AddAdsInternalUser();
|
||||||
|
|
||||||
|
// ✅ InternalRouting: Host solo necesita el writer (para agregar X-Internal-*)
|
||||||
|
builder.Services.AddInternalTenantDbHeaderWriter(builder.Configuration);
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IAdsUserCredentialValidator, HostAdsUserCredentialValidator>();
|
||||||
|
builder.Services.AddScoped<IAdsClientCredentialValidator, StubAdsClientCredentialValidator>();
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
|
||||||
|
// ✅ Muy importante: limpiar headers internos que vengan del cliente
|
||||||
|
app.Use((ctx, next) =>
|
||||||
|
{
|
||||||
|
ctx.Request.Headers.Remove(InternalHeaders.Tenant);
|
||||||
|
ctx.Request.Headers.Remove(InternalHeaders.Conn);
|
||||||
|
ctx.Request.Headers.Remove(InternalHeaders.Timestamp);
|
||||||
|
ctx.Request.Headers.Remove(InternalHeaders.Signature);
|
||||||
|
ctx.Request.Headers.Remove(InternalHeaders.CveUser);
|
||||||
|
ctx.Request.Headers.Remove(InternalHeaders.CveEmpresa);
|
||||||
|
ctx.Request.Headers.Remove(InternalHeaders.CveCliente);
|
||||||
|
ctx.Request.Headers.Remove(InternalHeaders.User_Timestamp);
|
||||||
|
ctx.Request.Headers.Remove(InternalHeaders.User_Signature);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseAdsMiddlewares();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
app.MapReverseProxy();
|
||||||
|
app.Run();
|
||||||
23
Apis/ADS.Host/Properties/launchSettings.json
Normal file
23
Apis/ADS.Host/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5000",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7000;http://localhost:5000",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
Apis/ADS.Host/appsettings.json
Normal file
62
Apis/ADS.Host/appsettings.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"MultiTenant": {
|
||||||
|
"HeaderName": "X-Tenant",
|
||||||
|
"DefaultTenantId": "demo",
|
||||||
|
"RequireTenant": false,
|
||||||
|
"BypassPaths": ["/swagger", "/health"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"Tenants": {
|
||||||
|
"demo": {
|
||||||
|
"TenantId": "demo",
|
||||||
|
"EmpresaId": 172,
|
||||||
|
"ClienteId": 1,
|
||||||
|
"Culture": "es-MX",
|
||||||
|
"Theme": "default",
|
||||||
|
"Database": {
|
||||||
|
"Provider": "SqlServer",
|
||||||
|
"ConnectionString": "Data Source=(local);Initial Catalog=GPS;User Id=GPSUsr;Password=Productivo2022#;pooling=True; Min Pool Size=10;Application Name=APIDemo"
|
||||||
|
},
|
||||||
|
"CustomSettings": {
|
||||||
|
"GpsVendor": "Concox"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Issuer": "ADS",
|
||||||
|
"Audience": "ADS_APIS",
|
||||||
|
"SigningKey": "SUPER_JCau127*953SECRET_KEY_CHANGE_THIS",
|
||||||
|
"ValidateLifetime": true,
|
||||||
|
"ClockSkewMinutes": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
"ReverseProxy": {
|
||||||
|
"Routes": {
|
||||||
|
"gpsRoute": {
|
||||||
|
"ClusterId": "gpsCluster",
|
||||||
|
"Match": {
|
||||||
|
"Path": "/gps/{**catch-all}"
|
||||||
|
},
|
||||||
|
"Transforms": [
|
||||||
|
{ "PathRemovePrefix": "/gps" }
|
||||||
|
],
|
||||||
|
"Metadata": {
|
||||||
|
"RequireInternalTenant": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Clusters": {
|
||||||
|
"gpsCluster": {
|
||||||
|
"Destinations": {
|
||||||
|
"gps": {
|
||||||
|
"Address": "http://localhost:5023/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"InternalRouting": {
|
||||||
|
"SharedSecret": "MISMO_SECRETO_DEL_HOST",
|
||||||
|
"MaxSkewSeconds": 120
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj
Normal file
17
Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Auth.Abstractions;
|
||||||
|
|
||||||
|
public sealed class AdsClientValidationResult
|
||||||
|
{
|
||||||
|
public bool IsValid { get; set; }
|
||||||
|
public string ClientId { get; set; } = default!;
|
||||||
|
|
||||||
|
public string? TenantId { get; set; }
|
||||||
|
public List<Claim> ExtraClaims { get; set; } = new();
|
||||||
|
|
||||||
|
public string? Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAdsClientCredentialValidator
|
||||||
|
{
|
||||||
|
Task<AdsClientValidationResult> ValidateAsync(
|
||||||
|
string clientId,
|
||||||
|
string clientSecret,
|
||||||
|
string? tenantId,
|
||||||
|
string? scope,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using ADS.Helpers.Auth.Models;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Auth.Abstractions;
|
||||||
|
|
||||||
|
public interface IAdsPrincipalAccessor
|
||||||
|
{
|
||||||
|
AdsPrincipal Current { get; }
|
||||||
|
bool IsAuthenticated { get; }
|
||||||
|
}
|
||||||
13
Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs
Normal file
13
Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using ADS.Helpers.Auth.Token;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Auth.Abstractions;
|
||||||
|
|
||||||
|
public interface IAdsTokenService
|
||||||
|
{
|
||||||
|
Task<AdsTokenResult> IssueAsync(AdsTokenRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<AdsTokenResult> IssueFromClaimsAsync(IReadOnlyCollection<Claim> claims, string? scope = null, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Auth.Abstractions;
|
||||||
|
|
||||||
|
public sealed class AdsUserValidationResult
|
||||||
|
{
|
||||||
|
public bool IsValid { get; set; }
|
||||||
|
public int CveUser { get; set; }
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
|
||||||
|
public int CveRole { get; set; }
|
||||||
|
public int CveEmpresa { get; set; }
|
||||||
|
public int CveCliente { get; set; }
|
||||||
|
|
||||||
|
public int[] CveModulos { get; set; } = [];
|
||||||
|
public string[] Permissions { get; set; } = [];
|
||||||
|
|
||||||
|
public List<Claim> ExtraClaims { get; set; } = new();
|
||||||
|
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public string? TenantId { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAdsUserCredentialValidator
|
||||||
|
{
|
||||||
|
Task<AdsUserValidationResult> ValidateAsync(
|
||||||
|
string userName,
|
||||||
|
string password,
|
||||||
|
string? tenantId,
|
||||||
|
string? scope,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
13
Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs
Normal file
13
Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ADS.Helpers.Auth.Claims;
|
||||||
|
|
||||||
|
public static class AdsClaimTypes
|
||||||
|
{
|
||||||
|
public const string CveUser = "ads:cve_user";
|
||||||
|
public const string TenantId = "ads:tenant_id";
|
||||||
|
public const string UserName = "ads:username";
|
||||||
|
public const string CveRole = "ads:cve_role";
|
||||||
|
public const string CveEmpresa = "ads:cve_empresa";
|
||||||
|
public const string CveCliente = "ads:cve_cliente";
|
||||||
|
public const string CveModulos = "ads:cve_modulos";
|
||||||
|
public const string Permissions = "ads:permissions";
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ADS.Helpers.Auth.Contracts.Sicrep.Auth;
|
||||||
|
|
||||||
|
public sealed class TokenRequest
|
||||||
|
{
|
||||||
|
public string ClientId { get; set; } = default!;
|
||||||
|
public string ClientSecret { get; set; } = default!;
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ADS.Helpers.Auth.Contracts.Sicrep.Auth;
|
||||||
|
|
||||||
|
public sealed class TokenResponse
|
||||||
|
{
|
||||||
|
public string AccessToken { get; set; } = default!;
|
||||||
|
public string TokenType { get; set; } = "Bearer";
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Text;
|
||||||
|
using ADS.Helpers.Auth.Abstractions;
|
||||||
|
using ADS.Helpers.Auth.Implementation;
|
||||||
|
using ADS.Helpers.Auth.Models;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Auth.Extensions;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAdsAuthentication(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
var jwtSection = configuration.GetSection("Jwt");
|
||||||
|
services.Configure<JwtOptions>(jwtSection);
|
||||||
|
|
||||||
|
var jwtOptions = jwtSection.Get<JwtOptions>()
|
||||||
|
?? throw new InvalidOperationException("Jwt configuration missing.");
|
||||||
|
|
||||||
|
var key = new SymmetricSecurityKey(
|
||||||
|
Encoding.UTF8.GetBytes(jwtOptions.SigningKey));
|
||||||
|
|
||||||
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = jwtOptions.ValidateLifetime,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = jwtOptions.Issuer,
|
||||||
|
ValidAudience = jwtOptions.Audience,
|
||||||
|
IssuerSigningKey = key,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(jwtOptions.ClockSkewMinutes)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddScoped<IAdsPrincipalAccessor, AdsPrincipalAccessor>();
|
||||||
|
|
||||||
|
// NUEVO: servicio emisor de token (requiere que el host registre validadores)
|
||||||
|
services.AddScoped<IAdsTokenService, AdsTokenService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAdsAuthorization(
|
||||||
|
this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddAuthorization();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using ADS.Helpers.Auth.Abstractions;
|
||||||
|
using ADS.Helpers.Auth.Claims;
|
||||||
|
using ADS.Helpers.Auth.Models;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Auth.Implementation;
|
||||||
|
|
||||||
|
internal sealed class AdsPrincipalAccessor : IAdsPrincipalAccessor
|
||||||
|
{
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
private AdsPrincipal? _cached;
|
||||||
|
|
||||||
|
public AdsPrincipalAccessor(IHttpContextAccessor httpContextAccessor)
|
||||||
|
{
|
||||||
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsAuthenticated =>
|
||||||
|
_httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true;
|
||||||
|
|
||||||
|
public AdsPrincipal Current
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_cached is not null)
|
||||||
|
return _cached;
|
||||||
|
|
||||||
|
var user = _httpContextAccessor.HttpContext?.User
|
||||||
|
?? throw new UnauthorizedAccessException("No HttpContext user.");
|
||||||
|
|
||||||
|
_cached = Map(user);
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdsPrincipal Map(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
int ParseInt(string type) =>
|
||||||
|
int.TryParse(user.FindFirst(type)?.Value, out var val) ? val : 0;
|
||||||
|
|
||||||
|
int[] ParseIntArray(string type) =>
|
||||||
|
user.FindAll(type)
|
||||||
|
.Select(x => int.TryParse(x.Value, out var v) ? v : 0)
|
||||||
|
.Where(x => x > 0)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
string[] ParseStringArray(string type) =>
|
||||||
|
user.FindAll(type)
|
||||||
|
.Select(x => x.Value)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new AdsPrincipal
|
||||||
|
{
|
||||||
|
CveUser = ParseInt(AdsClaimTypes.CveUser),
|
||||||
|
TenantId = AdsClaimTypes.TenantId,
|
||||||
|
UserName = user.FindFirst(AdsClaimTypes.UserName)?.Value,
|
||||||
|
CveRole = ParseInt(AdsClaimTypes.CveRole),
|
||||||
|
CveEmpresa = ParseInt(AdsClaimTypes.CveEmpresa),
|
||||||
|
CveCliente = ParseInt(AdsClaimTypes.CveCliente),
|
||||||
|
CveModulos = ParseIntArray(AdsClaimTypes.CveModulos),
|
||||||
|
Permissions = ParseStringArray(AdsClaimTypes.Permissions),
|
||||||
|
Claims = user.Claims.ToDictionary(x => x.Type, x => x.Value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
158
Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs
Normal file
158
Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using ADS.Helpers.Auth.Abstractions;
|
||||||
|
using ADS.Helpers.Auth.Claims;
|
||||||
|
using ADS.Helpers.Auth.Models;
|
||||||
|
using ADS.Helpers.Auth.Token;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Auth.Implementation;
|
||||||
|
|
||||||
|
internal sealed class AdsTokenService : IAdsTokenService
|
||||||
|
{
|
||||||
|
private readonly JwtOptions _options;
|
||||||
|
private readonly IAdsUserCredentialValidator _userValidator;
|
||||||
|
private readonly IAdsClientCredentialValidator _clientValidator;
|
||||||
|
|
||||||
|
public AdsTokenService(
|
||||||
|
IOptions<JwtOptions> options,
|
||||||
|
IAdsUserCredentialValidator userValidator,
|
||||||
|
IAdsClientCredentialValidator clientValidator)
|
||||||
|
{
|
||||||
|
_options = options.Value ?? throw new InvalidOperationException("JwtOptions missing.");
|
||||||
|
_userValidator = userValidator ?? throw new ArgumentNullException(nameof(userValidator));
|
||||||
|
_clientValidator = clientValidator ?? throw new ArgumentNullException(nameof(clientValidator));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdsTokenResult> IssueAsync(AdsTokenRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (request is null) throw new ArgumentNullException(nameof(request));
|
||||||
|
if (string.IsNullOrWhiteSpace(request.GrantType)) throw new InvalidOperationException("GrantType is required.");
|
||||||
|
|
||||||
|
var grant = request.GrantType.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (grant == AdsGrantTypes.Password)
|
||||||
|
return await IssuePasswordAsync(request, ct);
|
||||||
|
|
||||||
|
if (grant == AdsGrantTypes.ClientCredentials)
|
||||||
|
return await IssueClientCredentialsAsync(request, ct);
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Unsupported GrantType: {request.GrantType}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AdsTokenResult> IssuePasswordAsync(AdsTokenRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||||
|
throw new InvalidOperationException("UserName is required for password grant.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
throw new InvalidOperationException("Password is required for password grant.");
|
||||||
|
|
||||||
|
var validation = await _userValidator.ValidateAsync(
|
||||||
|
request.UserName.Trim(),
|
||||||
|
request.Password,
|
||||||
|
request.TenantId,
|
||||||
|
request.Scope,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (!validation.IsValid)
|
||||||
|
throw new UnauthorizedAccessException(validation.Error ?? "Invalid credentials.");
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(AdsClaimTypes.CveUser, validation.CveUser.ToString()),
|
||||||
|
new(AdsClaimTypes.UserName, validation.UserName ?? request.UserName.Trim()),
|
||||||
|
new(AdsClaimTypes.CveRole, validation.CveRole.ToString()),
|
||||||
|
new(AdsClaimTypes.CveEmpresa, validation.CveEmpresa.ToString()),
|
||||||
|
new(AdsClaimTypes.CveCliente, validation.CveCliente.ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.TenantId))
|
||||||
|
claims.Add(new Claim(AdsClaimTypes.TenantId, request.TenantId));
|
||||||
|
|
||||||
|
foreach (var m in validation.CveModulos.Where(x => x > 0))
|
||||||
|
claims.Add(new Claim(AdsClaimTypes.CveModulos, m.ToString()));
|
||||||
|
|
||||||
|
foreach (var p in validation.Permissions.Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||||
|
claims.Add(new Claim(AdsClaimTypes.Permissions, p));
|
||||||
|
|
||||||
|
if (validation.ExtraClaims.Count > 0)
|
||||||
|
claims.AddRange(validation.ExtraClaims);
|
||||||
|
|
||||||
|
return BuildToken(claims, request.Scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AdsTokenResult> IssueClientCredentialsAsync(AdsTokenRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ClientId))
|
||||||
|
throw new InvalidOperationException("ClientId is required for client_credentials grant.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ClientSecret))
|
||||||
|
throw new InvalidOperationException("ClientSecret is required for client_credentials grant.");
|
||||||
|
|
||||||
|
var validation = await _clientValidator.ValidateAsync(
|
||||||
|
request.ClientId.Trim(),
|
||||||
|
request.ClientSecret,
|
||||||
|
request.TenantId,
|
||||||
|
request.Scope,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (!validation.IsValid)
|
||||||
|
throw new UnauthorizedAccessException(validation.Error ?? "Invalid client credentials.");
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(AdsClaimTypes.UserName, validation.ClientId),
|
||||||
|
};
|
||||||
|
|
||||||
|
var tenantId = validation.TenantId ?? request.TenantId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.TenantId))
|
||||||
|
claims.Add(new Claim(AdsClaimTypes.TenantId, request.TenantId));
|
||||||
|
|
||||||
|
if (validation.ExtraClaims.Count > 0)
|
||||||
|
claims.AddRange(validation.ExtraClaims);
|
||||||
|
|
||||||
|
return BuildToken(claims, request.Scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdsTokenResult BuildToken(IReadOnlyCollection<Claim> claims, string? scope)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_options.SigningKey))
|
||||||
|
throw new InvalidOperationException("JwtOptions.SigningKey is required.");
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var expires = now.AddMinutes(_options.AccessTokenMinutes <= 0 ? 60 : _options.AccessTokenMinutes);
|
||||||
|
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
|
||||||
|
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
var jwt = new JwtSecurityToken(
|
||||||
|
issuer: _options.Issuer,
|
||||||
|
audience: _options.Audience,
|
||||||
|
claims: claims,
|
||||||
|
notBefore: now,
|
||||||
|
expires: expires,
|
||||||
|
signingCredentials: creds
|
||||||
|
);
|
||||||
|
|
||||||
|
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt);
|
||||||
|
|
||||||
|
return new AdsTokenResult
|
||||||
|
{
|
||||||
|
AccessToken = accessToken,
|
||||||
|
TokenType = "Bearer",
|
||||||
|
ExpiresIn = (int)Math.Max(0, (expires - now).TotalSeconds),
|
||||||
|
Scope = scope
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AdsTokenResult> IssueFromClaimsAsync(IReadOnlyCollection<Claim> claims, string? scope = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (claims is null) throw new ArgumentNullException(nameof(claims));
|
||||||
|
// ct no se usa, pero lo dejamos por consistencia
|
||||||
|
return Task.FromResult(BuildToken(claims, scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
22
Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs
Normal file
22
Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ADS.Helpers.Auth.Models;
|
||||||
|
|
||||||
|
public sealed class AdsPrincipal
|
||||||
|
{
|
||||||
|
public int CveUser { get; init; }
|
||||||
|
|
||||||
|
public string? TenantId { get; init; }
|
||||||
|
|
||||||
|
public string? UserName { get; init; }
|
||||||
|
|
||||||
|
public int CveRole { get; init; }
|
||||||
|
|
||||||
|
public int CveEmpresa { get; init; }
|
||||||
|
|
||||||
|
public int CveCliente { get; init; }
|
||||||
|
|
||||||
|
public int[] CveModulos { get; init; } = [];
|
||||||
|
|
||||||
|
public string[] Permissions { get; init; } = [];
|
||||||
|
|
||||||
|
public Dictionary<string, string> Claims { get; init; } = new();
|
||||||
|
}
|
||||||
11
Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs
Normal file
11
Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ADS.Helpers.Auth.Models;
|
||||||
|
|
||||||
|
public sealed class JwtOptions
|
||||||
|
{
|
||||||
|
public string Issuer { get; set; } = default!;
|
||||||
|
public string Audience { get; set; } = default!;
|
||||||
|
public string SigningKey { get; set; } = default!;
|
||||||
|
public bool ValidateLifetime { get; set; } = true;
|
||||||
|
public int ClockSkewMinutes { get; set; } = 5;
|
||||||
|
public int AccessTokenMinutes { get; set; } = 60;
|
||||||
|
}
|
||||||
26
Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs
Normal file
26
Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ADS.Helpers.Auth.Token;
|
||||||
|
|
||||||
|
public static class AdsGrantTypes
|
||||||
|
{
|
||||||
|
public const string Password = "password";
|
||||||
|
public const string ClientCredentials = "client_credentials";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdsTokenRequest
|
||||||
|
{
|
||||||
|
public string GrantType { get; set; } = AdsGrantTypes.Password;
|
||||||
|
|
||||||
|
// password
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
|
||||||
|
// client_credentials
|
||||||
|
public string? ClientId { get; set; }
|
||||||
|
public string? ClientSecret { get; set; }
|
||||||
|
|
||||||
|
// multitenant / contexto
|
||||||
|
public string? TenantId { get; set; }
|
||||||
|
|
||||||
|
// opcional: scope
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
}
|
||||||
9
Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs
Normal file
9
Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ADS.Helpers.Auth.Token;
|
||||||
|
|
||||||
|
public sealed class AdsTokenResult
|
||||||
|
{
|
||||||
|
public string AccessToken { get; set; } = default!;
|
||||||
|
public string TokenType { get; set; } = "Bearer";
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
}
|
||||||
44
Helpers/ADS.Helpers.Auth/readme.md
Normal file
44
Helpers/ADS.Helpers.Auth/readme.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
9️⃣ appsettings.json ejemplo
|
||||||
|
|
||||||
|
"Jwt": {
|
||||||
|
"Issuer": "ADS",
|
||||||
|
"Audience": "ADS_APIS",
|
||||||
|
"SigningKey": "SUPER_SECRET_KEY_CHANGE_THIS",
|
||||||
|
"ValidateLifetime": true,
|
||||||
|
"ClockSkewMinutes": 5
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
🔟 Cómo usarlo en el Host o API GPS
|
||||||
|
|
||||||
|
builder.Services.AddAdsAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddAdsAuthorization();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
Y en cualquier controlador:
|
||||||
|
|
||||||
|
public class GpsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAdsPrincipalAccessor _principal;
|
||||||
|
|
||||||
|
public GpsController(IAdsPrincipalAccessor principal)
|
||||||
|
{
|
||||||
|
_principal = principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult Get()
|
||||||
|
{
|
||||||
|
var user = _principal.Current;
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
user.CveUser,
|
||||||
|
user.TenantId,
|
||||||
|
user.CveEmpresa,
|
||||||
|
user.CveModulos
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj
Normal file
9
Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
8
Helpers/ADS.Helpers.Base/Abstractions/IAppLogger.cs
Normal file
8
Helpers/ADS.Helpers.Base/Abstractions/IAppLogger.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ADS.Helpers.Base.Abstractions;
|
||||||
|
|
||||||
|
public interface IAppLogger
|
||||||
|
{
|
||||||
|
void Info(string source, string code, string message, object? data = null);
|
||||||
|
void Warn(string source, string code, string message, object? data = null);
|
||||||
|
void Error(string source, string code, string message, Exception? ex = null, object? data = null);
|
||||||
|
}
|
||||||
6
Helpers/ADS.Helpers.Base/Class1.cs
Normal file
6
Helpers/ADS.Helpers.Base/Class1.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ADS.Helpers.Base;
|
||||||
|
|
||||||
|
public class Class1
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
9
Helpers/ADS.Helpers.Base/readme.md
Normal file
9
Helpers/ADS.Helpers.Base/readme.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
✅ 4️⃣ En tu API (Host)
|
||||||
|
|
||||||
|
En Program.cs:
|
||||||
|
|
||||||
|
builder.Services.AddAdsLogging();
|
||||||
|
builder.Services.AddAdsNotifyCenter();
|
||||||
|
|
||||||
|
|
||||||
|
Y listo.
|
||||||
18
Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj
Normal file
18
Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Data;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Data.Abstractions;
|
||||||
|
|
||||||
|
public interface ITenantDbConnectionFactory
|
||||||
|
{
|
||||||
|
IDbConnection Create();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ADS.Helpers.Data.Abstractions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Data.Extensions;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAdsTenantDb(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<ITenantDbConnectionFactory, TenantDbConnectionFactory>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Helpers/ADS.Helpers.Data/TenantDbConnectionFactory.cs
Normal file
26
Helpers/ADS.Helpers.Data/TenantDbConnectionFactory.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Data;
|
||||||
|
using ADS.Helpers.Data.Abstractions;
|
||||||
|
using ADS.Helpers.MultiTenant.Context; // o .Abstractions (según donde esté tu interfaz)
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Data;
|
||||||
|
|
||||||
|
public sealed class TenantDbConnectionFactory : ITenantDbConnectionFactory
|
||||||
|
{
|
||||||
|
private readonly ITenantContextAccessor _tenantAccessor;
|
||||||
|
|
||||||
|
public TenantDbConnectionFactory(ITenantContextAccessor tenantAccessor)
|
||||||
|
=> _tenantAccessor = tenantAccessor;
|
||||||
|
|
||||||
|
public IDbConnection Create()
|
||||||
|
{
|
||||||
|
var tenant = _tenantAccessor.Current
|
||||||
|
?? throw new InvalidOperationException("TENANT_NOT_RESOLVED");
|
||||||
|
|
||||||
|
var cs = tenant.Database?.ConnectionString;
|
||||||
|
if (string.IsNullOrWhiteSpace(cs))
|
||||||
|
throw new InvalidOperationException("TENANT_CONNECTION_STRING_MISSING");
|
||||||
|
|
||||||
|
return new SqlConnection(cs);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Helpers/ADS.Helpers.Data/readme.md
Normal file
15
Helpers/ADS.Helpers.Data/readme.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
7) Usarlo en cualquier micro (GPS incluido)
|
||||||
|
|
||||||
|
En el Program.cs del micro:
|
||||||
|
|
||||||
|
using ADS.Helpers.Data.Extensions;
|
||||||
|
|
||||||
|
builder.Services.AddAdsMiddlewares(builder.Configuration); // tu pipeline tenant
|
||||||
|
builder.Services.AddAdsTenantDb();
|
||||||
|
|
||||||
|
|
||||||
|
Y en repositorios (Dapper):
|
||||||
|
|
||||||
|
using ADS.Helpers.Data.Abstractions;
|
||||||
|
|
||||||
|
using var cn = _dbFactory.Create();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ADS.Helpers.Localization.Abstractions;
|
||||||
|
|
||||||
|
public interface IMessageCatalog
|
||||||
|
{
|
||||||
|
string Get(string source,string code, string culture);
|
||||||
|
}
|
||||||
6
Helpers/ADS.Helpers.Localization/Class1.cs
Normal file
6
Helpers/ADS.Helpers.Localization/Class1.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ADS.Helpers.Localization;
|
||||||
|
|
||||||
|
public class Class1
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ADS.Helpers.Localization.Abstractions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Localization.Extensions;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAdsLocalization(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<InMemoryMessageCatalog>();
|
||||||
|
services.AddSingleton<IMessageCatalog>(sp => sp.GetRequiredService<InMemoryMessageCatalog>());
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Helpers/ADS.Helpers.Localization/InMemoryMessageCatalog.cs
Normal file
28
Helpers/ADS.Helpers.Localization/InMemoryMessageCatalog.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ADS.Helpers.Localization.Abstractions;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Localization;
|
||||||
|
|
||||||
|
public sealed class InMemoryMessageCatalog : IMessageCatalog
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<(string source, string code, string culture), string> _messages = new();
|
||||||
|
|
||||||
|
public InMemoryMessageCatalog Seed(string source, string culture, IReadOnlyDictionary<string, string> messages)
|
||||||
|
{
|
||||||
|
foreach (var kv in messages)
|
||||||
|
_messages[(source, kv.Key, culture)] = kv.Value;
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Get(string source, string code, string culture)
|
||||||
|
{
|
||||||
|
if (_messages.TryGetValue((source, code, culture), out var msg))
|
||||||
|
return msg;
|
||||||
|
|
||||||
|
if (_messages.TryGetValue((source, code, "en-US"), out var en))
|
||||||
|
return en;
|
||||||
|
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj
Normal file
22
Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
|
||||||
|
<ProjectReference Include="..\ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Middleware.ADSException;
|
||||||
|
|
||||||
|
using ADS.Helpers.Middleware.Http;
|
||||||
|
|
||||||
|
public sealed class AdsExceptionMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly IAdsErrorLogger _logger;
|
||||||
|
|
||||||
|
public AdsExceptionMiddleware(RequestDelegate next, IAdsErrorLogger logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
catch (System.UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
await Handle(context, HttpStatusCode.Unauthorized, ex, AdsErrorKind.Controlled);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
await Handle(context, HttpStatusCode.BadRequest, ex, AdsErrorKind.Controlled);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
await Handle(context, HttpStatusCode.BadRequest, ex, AdsErrorKind.Controlled);
|
||||||
|
}
|
||||||
|
catch (Microsoft.Data.SqlClient.SqlException ex)
|
||||||
|
{
|
||||||
|
await Handle(context, HttpStatusCode.InternalServerError, ex, AdsErrorKind.Sql);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Handle(context, HttpStatusCode.InternalServerError, ex, AdsErrorKind.Unhandled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Handle(HttpContext ctx, HttpStatusCode code, Exception ex, AdsErrorKind kind)
|
||||||
|
{
|
||||||
|
await _logger.LogAsync(new AdsErrorEvent
|
||||||
|
{
|
||||||
|
Kind = kind,
|
||||||
|
Path = ctx.Request.Path.ToString(),
|
||||||
|
Method = ctx.Request.Method,
|
||||||
|
TraceId = ctx.TraceIdentifier,
|
||||||
|
Message = ex.Message,
|
||||||
|
Exception = ex.ToString()
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.Response.StatusCode = (int)code;
|
||||||
|
ctx.Response.ContentType = "application/json";
|
||||||
|
|
||||||
|
var payload = new ApiResponse<object>
|
||||||
|
{
|
||||||
|
StatusCode = (int)code,
|
||||||
|
StatusCodeText = code.ToString(),
|
||||||
|
ConsoleMessage = ex.Message,
|
||||||
|
ScreenMessage = new UiMessage
|
||||||
|
{
|
||||||
|
MessageType = 2, // ejemplo: 1=info,2=error,3=warn
|
||||||
|
Message = "Ocurrió un error al procesar la solicitud.",
|
||||||
|
MessageCode = 0
|
||||||
|
},
|
||||||
|
Value = null
|
||||||
|
};
|
||||||
|
|
||||||
|
await ctx.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Middleware.ADSException;
|
||||||
|
|
||||||
|
public sealed class FileAdsErrorLogger : IAdsErrorLogger
|
||||||
|
{
|
||||||
|
private readonly string _folder;
|
||||||
|
|
||||||
|
public FileAdsErrorLogger(IOptions<LoggingOptions> options)
|
||||||
|
{
|
||||||
|
_folder = options.Value.LogFolder ?? "logs";
|
||||||
|
Directory.CreateDirectory(_folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LogAsync(AdsErrorEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var day = DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||||
|
var path = Path.Combine(_folder, $"ads-errors-{day}.jsonl");
|
||||||
|
|
||||||
|
var line = JsonSerializer.Serialize(evt);
|
||||||
|
return File.AppendAllTextAsync(path, line + Environment.NewLine, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace ADS.Helpers.Middleware.ADSException;
|
||||||
|
|
||||||
|
public enum AdsErrorKind
|
||||||
|
{
|
||||||
|
Sql = 1,
|
||||||
|
Controlled = 2,
|
||||||
|
Unhandled = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdsErrorEvent
|
||||||
|
{
|
||||||
|
public AdsErrorKind Kind { get; init; }
|
||||||
|
public string? Path { get; init; }
|
||||||
|
public string? Method { get; init; }
|
||||||
|
public string? TraceId { get; init; }
|
||||||
|
public string? Message { get; init; }
|
||||||
|
public string? Exception { get; init; }
|
||||||
|
public DateTime Utc { get; init; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAdsErrorLogger
|
||||||
|
{
|
||||||
|
Task LogAsync(AdsErrorEvent evt, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ADS.Helpers.Middleware.ADSException;
|
||||||
|
|
||||||
|
public sealed class LoggingOptions
|
||||||
|
{
|
||||||
|
public string LogFolder { get; set; } = "logs";
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using ADS.Helpers.Middleware.ADSException;
|
||||||
|
using ADS.Helpers.Middleware.Http;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Middleware.ADSExtensions;
|
||||||
|
|
||||||
|
public static class AdsApiResponseExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAdsApiResponse(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
// Leer configuración desde appsettings de la API
|
||||||
|
services.Configure<LoggingOptions>(
|
||||||
|
configuration.GetSection("LoggingOptions"));
|
||||||
|
|
||||||
|
services.AddSingleton<IAdsErrorLogger, FileAdsErrorLogger>();
|
||||||
|
|
||||||
|
services.AddControllers(o =>
|
||||||
|
{
|
||||||
|
o.Filters.Add<ApiResponseWrapFilter>();
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IApplicationBuilder UseAdsApiResponse(this IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
app.UseMiddleware<AdsExceptionMiddleware>();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
Helpers/ADS.Helpers.Middleware/Extensions/AdsExtensions.cs
Normal file
76
Helpers/ADS.Helpers.Middleware/Extensions/AdsExtensions.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
using ADS.Helpers.Middleware.Tenant;
|
||||||
|
using ADS.Helpers.Middleware.Logging;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ADS.Helpers.MultiTenant.Abstractions;
|
||||||
|
using ADS.Helpers.MultiTenant.Resolvers;
|
||||||
|
using ADS.Helpers.MultiTenant.Context;
|
||||||
|
using ADS.Helpers.Base.Abstractions;
|
||||||
|
using ADS.Helpers.MultiTenant.Configuration;
|
||||||
|
using ADS.Helpers.MultiTenant.Stores;
|
||||||
|
using ADS.Helpers.Middleware.InternalUser;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Middleware.Extensions;
|
||||||
|
|
||||||
|
public static class AdsExtensions
|
||||||
|
{
|
||||||
|
// IServiceCollection extensions
|
||||||
|
public static IServiceCollection AddAdsLogging(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
//services.AddLogging();
|
||||||
|
|
||||||
|
// Ejemplo si tenías esto:
|
||||||
|
services.AddSingleton<IAppLogger, DefaultAppLogger>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAdsMultiTenant(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
// Aquí va lo que ya tengas para tenant: options, accessor, resolver, etc.
|
||||||
|
services.Configure<TenantOptions>(configuration.GetSection("MultiTenant"));
|
||||||
|
services.AddSingleton<ITenantContextAccessor, TenantContextAccessor>();
|
||||||
|
// services.AddSingleton<ITenantStore, InMemoryTenantStore>();// reemplazado por ConfigurationTenantStore
|
||||||
|
services.AddSingleton<ITenantStore, ConfigurationTenantStore>();
|
||||||
|
services.AddScoped<ITenantResolver, HeaderTenantResolver>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAdsMiddlewares(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
// Si este método es solo un "orquestador" de otros registros:
|
||||||
|
services.AddAdsLogging(configuration);
|
||||||
|
services.AddAdsMultiTenant(configuration);
|
||||||
|
|
||||||
|
// Y aquí agregas otros (ExceptionHandling, CorrelationId, etc.)
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IApplicationBuilder extensions
|
||||||
|
public static IApplicationBuilder UseAdsTenant(this IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
return app.UseMiddleware<TenantMiddleware>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IApplicationBuilder UseAdsMiddlewares(this IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
// Orden típico: errores/correlation/tenant/etc (según tu diseño)
|
||||||
|
app.UseAdsTenant();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAdsInternalUser(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IInternalUserContextAccessor, InternalUserContextAccessor>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IApplicationBuilder UseAdsInternalUser(this IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
return app.UseMiddleware<InternalUserContextMiddleware>();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
20
Helpers/ADS.Helpers.Middleware/Http/ApiResponse.cs
Normal file
20
Helpers/ADS.Helpers.Middleware/Http/ApiResponse.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ADS.Helpers.Middleware.Http;
|
||||||
|
|
||||||
|
public sealed class ApiResponse<T>
|
||||||
|
{
|
||||||
|
public int StatusCode { get; init; }
|
||||||
|
public int TotalRows { get; init; }
|
||||||
|
public string StatusCodeText { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? ConsoleMessage { get; init; }
|
||||||
|
public UiMessage? ScreenMessage { get; init; }
|
||||||
|
|
||||||
|
public T? Value { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UiMessage
|
||||||
|
{
|
||||||
|
public int MessageType { get; init; }
|
||||||
|
public string Message { get; init; } = "";
|
||||||
|
public int MessageCode { get; init; }
|
||||||
|
}
|
||||||
62
Helpers/ADS.Helpers.Middleware/Http/ApiResponseWrapFilter.cs
Normal file
62
Helpers/ADS.Helpers.Middleware/Http/ApiResponseWrapFilter.cs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Middleware.Http;
|
||||||
|
|
||||||
|
public sealed class ApiResponseWrapFilter : IAsyncResultFilter
|
||||||
|
{
|
||||||
|
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
|
||||||
|
{
|
||||||
|
// Si es FileResult / Stream / etc., no lo toques
|
||||||
|
if (context.Result is FileResult)
|
||||||
|
{
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.Result is ObjectResult obj)
|
||||||
|
{
|
||||||
|
// Si ya viene envuelto, no reenvolver
|
||||||
|
if (obj.Value is not null)
|
||||||
|
{
|
||||||
|
var t = obj.Value.GetType();
|
||||||
|
if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(ApiResponse<>))
|
||||||
|
{
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var code = obj.StatusCode ?? 200;
|
||||||
|
context.Result = new ObjectResult(new ApiResponse<object>
|
||||||
|
{
|
||||||
|
StatusCode = code,
|
||||||
|
StatusCodeText = ReasonPhrase(code),
|
||||||
|
Value = obj.Value
|
||||||
|
})
|
||||||
|
{ StatusCode = code };
|
||||||
|
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no es ObjectResult (por ejemplo EmptyResult), déjalo pasar
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReasonPhrase(int code) => code switch
|
||||||
|
{
|
||||||
|
200 => "OK",
|
||||||
|
201 => "Created",
|
||||||
|
202 => "Accepted",
|
||||||
|
204 => "NoContent",
|
||||||
|
400 => "BadRequest",
|
||||||
|
401 => "Unauthorized",
|
||||||
|
403 => "Forbidden",
|
||||||
|
404 => "NotFound",
|
||||||
|
409 => "Conflict",
|
||||||
|
422 => "UnprocessableEntity",
|
||||||
|
500 => "ServerError",
|
||||||
|
_ => "Status"
|
||||||
|
};
|
||||||
|
}
|
||||||
9
Helpers/ADS.Helpers.Middleware/Http/PagedQuery.cs
Normal file
9
Helpers/ADS.Helpers.Middleware/Http/PagedQuery.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ADS.Helpers.Middleware.Http;
|
||||||
|
|
||||||
|
public sealed class PagedQuery
|
||||||
|
{
|
||||||
|
public string? Search { get; init; }
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
public int PageSize { get; init; } = 50;
|
||||||
|
public bool Pagination { get; init; } = true;
|
||||||
|
}
|
||||||
11
Helpers/ADS.Helpers.Middleware/Http/RequestOf.cs
Normal file
11
Helpers/ADS.Helpers.Middleware/Http/RequestOf.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ADS.Helpers.Middleware.Http;
|
||||||
|
|
||||||
|
public sealed class RequestOf<T>
|
||||||
|
{
|
||||||
|
public T? Value { get; init; }
|
||||||
|
|
||||||
|
public string? Search { get; init; }
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
public int PageSize { get; init; } = 50;
|
||||||
|
public bool Pagination { get; init; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ADS.Helpers.Middleware.InternalUser;
|
||||||
|
|
||||||
|
public interface IInternalUserContextAccessor
|
||||||
|
{
|
||||||
|
InternalUserContext? Current { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Middleware.InternalUser;
|
||||||
|
|
||||||
|
public class InternalAuthorizeAttribute : Attribute, IAuthorizationFilter
|
||||||
|
{
|
||||||
|
public void OnAuthorization(AuthorizationFilterContext context)
|
||||||
|
{
|
||||||
|
var allowAnonymous = context.ActionDescriptor.EndpointMetadata
|
||||||
|
.OfType<AllowAnonymousAttribute>()
|
||||||
|
.Any();
|
||||||
|
|
||||||
|
if (allowAnonymous)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var accessor = context.HttpContext.RequestServices
|
||||||
|
.GetService(typeof(IInternalUserContextAccessor)) as IInternalUserContextAccessor;
|
||||||
|
|
||||||
|
if (accessor?.Current == null)
|
||||||
|
{
|
||||||
|
context.Result = new UnauthorizedResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ADS.Helpers.Middleware.InternalUser;
|
||||||
|
|
||||||
|
public class InternalUserContext
|
||||||
|
{
|
||||||
|
public int CveUser { get; set; }
|
||||||
|
public int CveEmpresa { get; set; }
|
||||||
|
public int CveCliente { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ADS.Helpers.Middleware.InternalUser;
|
||||||
|
|
||||||
|
public class InternalUserContextAccessor : IInternalUserContextAccessor
|
||||||
|
{
|
||||||
|
public InternalUserContext? Current { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using ADS.Helpers.MultiTenant.InternalRouting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Middleware.InternalUser;
|
||||||
|
|
||||||
|
public class InternalUserContextMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
public InternalUserContextMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(
|
||||||
|
HttpContext context,
|
||||||
|
IInternalUserContextAccessor accessor,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var cveUserHeader = context.Request.Headers[InternalHeaders.CveUser].FirstOrDefault();
|
||||||
|
var cveEmpresaHeader = context.Request.Headers[InternalHeaders.CveEmpresa].FirstOrDefault();
|
||||||
|
var cveClienteHeader = context.Request.Headers[InternalHeaders.CveCliente].FirstOrDefault();
|
||||||
|
var timestampHeader = context.Request.Headers[InternalHeaders.Timestamp].FirstOrDefault();
|
||||||
|
var signatureHeader = context.Request.Headers[InternalHeaders.User_Signature].FirstOrDefault();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(cveUserHeader) &&
|
||||||
|
!string.IsNullOrWhiteSpace(cveEmpresaHeader) &&
|
||||||
|
!string.IsNullOrWhiteSpace(cveClienteHeader) &&
|
||||||
|
!string.IsNullOrWhiteSpace(timestampHeader) &&
|
||||||
|
!string.IsNullOrWhiteSpace(signatureHeader))
|
||||||
|
{
|
||||||
|
var secret = configuration["InternalRouting:SharedSecret"];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
|
throw new InvalidOperationException("InternalRouting:SharedSecret no está configurado.");
|
||||||
|
|
||||||
|
var raw = $"{cveUserHeader}|{cveEmpresaHeader}|{cveClienteHeader}|{timestampHeader}";
|
||||||
|
var expectedSignature = ComputeHmacSha256(raw, secret);
|
||||||
|
|
||||||
|
if (signatureHeader == expectedSignature)
|
||||||
|
{
|
||||||
|
accessor.Current = new InternalUserContext
|
||||||
|
{
|
||||||
|
CveUser = int.Parse(cveUserHeader),
|
||||||
|
CveEmpresa = int.Parse(cveEmpresaHeader),
|
||||||
|
CveCliente = int.Parse(cveClienteHeader)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeHmacSha256(string value, string secret)
|
||||||
|
{
|
||||||
|
var key = Encoding.UTF8.GetBytes(secret);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(value);
|
||||||
|
|
||||||
|
using var hmac = new HMACSHA256(key);
|
||||||
|
var hash = hmac.ComputeHash(bytes);
|
||||||
|
|
||||||
|
return Convert.ToBase64String(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Helpers/ADS.Helpers.Middleware/Logging/DefaultAppLogger.cs
Normal file
24
Helpers/ADS.Helpers.Middleware/Logging/DefaultAppLogger.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using System;
|
||||||
|
using ADS.Helpers.Base.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Middleware.Logging;
|
||||||
|
|
||||||
|
public sealed class DefaultAppLogger : IAppLogger
|
||||||
|
{
|
||||||
|
private readonly ILogger<DefaultAppLogger> _logger;
|
||||||
|
|
||||||
|
public DefaultAppLogger(ILogger<DefaultAppLogger> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Info(string source, string code, string message, object? data = null)
|
||||||
|
=> _logger.LogInformation("{Source} {Code} {Message} {@Data}", source, code, message, data);
|
||||||
|
|
||||||
|
public void Warn(string source, string code, string message, object? data = null)
|
||||||
|
=> _logger.LogWarning("{Source} {Code} {Message} {@Data}", source, code, message, data);
|
||||||
|
|
||||||
|
public void Error(string source, string code, string message, Exception? ex = null, object? data = null)
|
||||||
|
=> _logger.LogError(ex, "{Source} {Code} {Message} {@Data}", source, code, message, data);
|
||||||
|
}
|
||||||
57
Helpers/ADS.Helpers.Middleware/Tenant/TenantMiddleware.cs
Normal file
57
Helpers/ADS.Helpers.Middleware/Tenant/TenantMiddleware.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using ADS.Helpers.Base.Abstractions;
|
||||||
|
using ADS.Helpers.MultiTenant.Abstractions;
|
||||||
|
using ADS.Helpers.MultiTenant.Context;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.Middleware.Tenant;
|
||||||
|
|
||||||
|
public sealed class TenantMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
public TenantMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped dependencies se resuelven aquí (por request)
|
||||||
|
public async Task InvokeAsync(
|
||||||
|
HttpContext context,
|
||||||
|
ITenantResolver tenantResolver,
|
||||||
|
ITenantStore tenantStore,
|
||||||
|
ITenantContextAccessor tenantContextAccessor,
|
||||||
|
IAppLogger log)
|
||||||
|
{
|
||||||
|
var tenantKey = tenantResolver.Resolve(context);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(tenantKey))
|
||||||
|
{
|
||||||
|
log.Warn("ADS.MultiTenant", "TENANT_NOT_RESOLVED", "Tenant key could not be resolved.");
|
||||||
|
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||||
|
await context.Response.WriteAsync("Tenant not resolved.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenant = await tenantStore.GetAsync(tenantKey);
|
||||||
|
|
||||||
|
if (tenant is null)
|
||||||
|
{
|
||||||
|
log.Warn("ADS.MultiTenant", "TENANT_NOT_FOUND", $"Tenant '{tenantKey}' not found.");
|
||||||
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
await context.Response.WriteAsync("Tenant not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantContextAccessor.SetCurrent(tenant);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
tenantContextAccessor.SetCurrent(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.MultiTenant.Abstractions;
|
||||||
|
|
||||||
|
public interface ITenantResolver
|
||||||
|
{
|
||||||
|
string? Resolve(HttpContext context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using ADS.Helpers.MultiTenant.Context;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.MultiTenant.Abstractions;
|
||||||
|
|
||||||
|
public interface ITenantStore
|
||||||
|
{
|
||||||
|
Task<TenantContext?> GetAsync(string tenantKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ADS.Helpers.MultiTenant.Configuration;
|
||||||
|
|
||||||
|
public sealed class TenantOptions
|
||||||
|
{
|
||||||
|
public string HeaderName { get; set; } = "X-Tenant";
|
||||||
|
public string? DefaultTenantId { get; set; }="demo";
|
||||||
|
public bool RequireTenant { get; set; } = false;
|
||||||
|
|
||||||
|
// Para no romper swagger
|
||||||
|
public string[] BypassPaths { get; set; } = ["/swagger"];
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ADS.Helpers.MultiTenant.Context;
|
||||||
|
|
||||||
|
public interface ITenantContextAccessor
|
||||||
|
{
|
||||||
|
TenantContext? Current { get; }
|
||||||
|
void SetCurrent(TenantContext? context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using ADS.Helpers.MultiTenant.Abstractions;
|
||||||
|
using ADS.Helpers.MultiTenant.Context;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.MultiTenant.Stores;
|
||||||
|
|
||||||
|
public sealed class InMemoryTenantStore : ITenantStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, TenantContext> _tenants = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["demo"] = new TenantContext { TenantId = "demo", EmpresaId = 172, ClienteId = 1 }
|
||||||
|
};
|
||||||
|
public Task<TenantContext?> GetAsync(string tenantKey)
|
||||||
|
{
|
||||||
|
_tenants.TryGetValue(tenantKey, out var tenant);
|
||||||
|
return Task.FromResult<TenantContext?>(tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Helpers/ADS.Helpers.MultiTenant/Context/TenantContext.cs
Normal file
22
Helpers/ADS.Helpers.MultiTenant/Context/TenantContext.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ADS.Helpers.MultiTenant.Context;
|
||||||
|
|
||||||
|
public sealed class TenantContext
|
||||||
|
{
|
||||||
|
public int EmpresaId { get; set; }
|
||||||
|
public int ClienteId { get; set; }
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Culture { get; set; } = "es-MX";
|
||||||
|
public string Theme { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DatabaseConfig Database { get; set; } = new();
|
||||||
|
|
||||||
|
// Settings no tipados (solo si quieres algo rápido para cosas raras)
|
||||||
|
public Dictionary<string, string> CustomSettings { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DatabaseConfig
|
||||||
|
{
|
||||||
|
public string Provider { get; set; } = "SqlServer"; // SqlServer | Postgres
|
||||||
|
public string ConnectionString { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.MultiTenant.Context;
|
||||||
|
|
||||||
|
public sealed class TenantContextAccessor : ITenantContextAccessor
|
||||||
|
{
|
||||||
|
private static readonly AsyncLocal<TenantContext?> _current = new();
|
||||||
|
|
||||||
|
public TenantContext? Current => _current.Value;
|
||||||
|
|
||||||
|
public void SetCurrent(TenantContext? context) => _current.Value = context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Data;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.MultiTenant.Data;
|
||||||
|
|
||||||
|
public interface IDbConnectionFactory
|
||||||
|
{
|
||||||
|
IDbConnection Create();
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Data;
|
||||||
|
using ADS.Helpers.MultiTenant.InternalRouting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
namespace ADS.Helpers.MultiTenant.Data;
|
||||||
|
|
||||||
|
public sealed class TenantDbConnectionFactory : IDbConnectionFactory
|
||||||
|
{
|
||||||
|
private readonly IHttpContextAccessor _http;
|
||||||
|
|
||||||
|
public TenantDbConnectionFactory(IHttpContextAccessor http) => _http = http;
|
||||||
|
|
||||||
|
public IDbConnection Create()
|
||||||
|
{
|
||||||
|
var ctx = _http.HttpContext ?? throw new InvalidOperationException("HTTP_CONTEXT_NOT_AVAILABLE");
|
||||||
|
|
||||||
|
if (!ctx.Items.TryGetValue(InternalTenantDbHeaders.ItemConn, out var csObj) ||
|
||||||
|
csObj is not string cs ||
|
||||||
|
string.IsNullOrWhiteSpace(cs))
|
||||||
|
throw new InvalidOperationException("INTERNAL_CONN_MISSING");
|
||||||
|
|
||||||
|
return new SqlConnection(cs);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user