First Commit

This commit is contained in:
Jorge Luis caudillo esparza
2026-05-09 16:36:57 -07:00
parent 96ad4c0d3e
commit d0c284d9c0
131 changed files with 4009 additions and 0 deletions

View 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>

View File

@@ -0,0 +1,6 @@
@ADS.Apis.Gps_HostAddress = http://localhost:5286
GET {{ADS.Apis.Gps_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
namespace ADS.Apis.Gps.Application.Common.Crypto;
public interface ILegacyPasswordEncryptor
{
string Encrypt(string plainText);
}

View 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();
}
}

View File

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

View File

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

View 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;
}

View 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;
}

View 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; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
// }
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();

View 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"
}
}
}
}

View 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": "*"
}

View 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
View 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>

View 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>

View File

@@ -0,0 +1,6 @@
@ADS.Host_HostAddress = http://localhost:5030
GET {{ADS.Host_HostAddress}}/weatherforecast/
Accept: application/json
###

View 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; }
}
}

View 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."
});
}
}

View 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."
});
}
}

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

View 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
View 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();

View 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"
}
}
}
}

View 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
}
}

View 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>

View File

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

View File

@@ -0,0 +1,9 @@
using ADS.Helpers.Auth.Models;
namespace ADS.Helpers.Auth.Abstractions;
public interface IAdsPrincipalAccessor
{
AdsPrincipal Current { get; }
bool IsAuthenticated { get; }
}

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

View File

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

View 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";
}

View File

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

View File

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

View File

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

View File

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

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

View 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();
}

View 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;
}

View 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; }
}

View 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; }
}

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

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

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

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Base;
public class Class1
{
}

View File

@@ -0,0 +1,9 @@
✅ 4️⃣ En tu API (Host)
En Program.cs:
builder.Services.AddAdsLogging();
builder.Services.AddAdsNotifyCenter();
Y listo.

View 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>

View File

@@ -0,0 +1,8 @@
using System.Data;
namespace ADS.Helpers.Data.Abstractions;
public interface ITenantDbConnectionFactory
{
IDbConnection Create();
}

View File

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

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

View 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();

View File

@@ -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>

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Localization.Abstractions;
public interface IMessageCatalog
{
string Get(string source,string code, string culture);
}

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Localization;
public class Class1
{
}

View File

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

View 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;
}
}

View 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>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Middleware.ADSException;
public sealed class LoggingOptions
{
public string LogFolder { get; set; } = "logs";
}

View File

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

View 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>();
}
}

View 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; }
}

View 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"
};
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Middleware.InternalUser;
public interface IInternalUserContextAccessor
{
InternalUserContext? Current { get; set; }
}

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Middleware.InternalUser;
public class InternalUserContextAccessor : IInternalUserContextAccessor
{
public InternalUserContext? Current { get; set; }
}

View File

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

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

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

View File

@@ -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>

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Http;
namespace ADS.Helpers.MultiTenant.Abstractions;
public interface ITenantResolver
{
string? Resolve(HttpContext context);
}

View File

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

View File

@@ -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"];
}

View File

@@ -0,0 +1,7 @@
namespace ADS.Helpers.MultiTenant.Context;
public interface ITenantContextAccessor
{
TenantContext? Current { get; }
void SetCurrent(TenantContext? context);
}

View File

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

View 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;
}

View File

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

View File

@@ -0,0 +1,8 @@
using System.Data;
namespace ADS.Helpers.MultiTenant.Data;
public interface IDbConnectionFactory
{
IDbConnection Create();
}

View File

@@ -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