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