First Commit
This commit is contained in:
17
Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj
Normal file
17
Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj
Normal file
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ADS.Helpers.Auth.Abstractions;
|
||||
|
||||
public sealed class AdsClientValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public string ClientId { get; set; } = default!;
|
||||
|
||||
public string? TenantId { get; set; }
|
||||
public List<Claim> ExtraClaims { get; set; } = new();
|
||||
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
public interface IAdsClientCredentialValidator
|
||||
{
|
||||
Task<AdsClientValidationResult> ValidateAsync(
|
||||
string clientId,
|
||||
string clientSecret,
|
||||
string? tenantId,
|
||||
string? scope,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ADS.Helpers.Auth.Models;
|
||||
|
||||
namespace ADS.Helpers.Auth.Abstractions;
|
||||
|
||||
public interface IAdsPrincipalAccessor
|
||||
{
|
||||
AdsPrincipal Current { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
}
|
||||
13
Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs
Normal file
13
Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using ADS.Helpers.Auth.Token;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ADS.Helpers.Auth.Abstractions;
|
||||
|
||||
public interface IAdsTokenService
|
||||
{
|
||||
Task<AdsTokenResult> IssueAsync(AdsTokenRequest request, CancellationToken ct = default);
|
||||
|
||||
Task<AdsTokenResult> IssueFromClaimsAsync(IReadOnlyCollection<Claim> claims, string? scope = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ADS.Helpers.Auth.Abstractions;
|
||||
|
||||
public sealed class AdsUserValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public int CveUser { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
|
||||
public int CveRole { get; set; }
|
||||
public int CveEmpresa { get; set; }
|
||||
public int CveCliente { get; set; }
|
||||
|
||||
public int[] CveModulos { get; set; } = [];
|
||||
public string[] Permissions { get; set; } = [];
|
||||
|
||||
public List<Claim> ExtraClaims { get; set; } = new();
|
||||
|
||||
public string? Error { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
|
||||
}
|
||||
|
||||
public interface IAdsUserCredentialValidator
|
||||
{
|
||||
Task<AdsUserValidationResult> ValidateAsync(
|
||||
string userName,
|
||||
string password,
|
||||
string? tenantId,
|
||||
string? scope,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
13
Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs
Normal file
13
Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace ADS.Helpers.Auth.Claims;
|
||||
|
||||
public static class AdsClaimTypes
|
||||
{
|
||||
public const string CveUser = "ads:cve_user";
|
||||
public const string TenantId = "ads:tenant_id";
|
||||
public const string UserName = "ads:username";
|
||||
public const string CveRole = "ads:cve_role";
|
||||
public const string CveEmpresa = "ads:cve_empresa";
|
||||
public const string CveCliente = "ads:cve_cliente";
|
||||
public const string CveModulos = "ads:cve_modulos";
|
||||
public const string Permissions = "ads:permissions";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ADS.Helpers.Auth.Contracts.Sicrep.Auth;
|
||||
|
||||
public sealed class TokenRequest
|
||||
{
|
||||
public string ClientId { get; set; } = default!;
|
||||
public string ClientSecret { get; set; } = default!;
|
||||
public string? Scope { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ADS.Helpers.Auth.Contracts.Sicrep.Auth;
|
||||
|
||||
public sealed class TokenResponse
|
||||
{
|
||||
public string AccessToken { get; set; } = default!;
|
||||
public string TokenType { get; set; } = "Bearer";
|
||||
public int ExpiresIn { get; set; }
|
||||
public string? Scope { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Text;
|
||||
using ADS.Helpers.Auth.Abstractions;
|
||||
using ADS.Helpers.Auth.Implementation;
|
||||
using ADS.Helpers.Auth.Models;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace ADS.Helpers.Auth.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdsAuthentication(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
var jwtSection = configuration.GetSection("Jwt");
|
||||
services.Configure<JwtOptions>(jwtSection);
|
||||
|
||||
var jwtOptions = jwtSection.Get<JwtOptions>()
|
||||
?? throw new InvalidOperationException("Jwt configuration missing.");
|
||||
|
||||
var key = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(jwtOptions.SigningKey));
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = jwtOptions.ValidateLifetime,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromMinutes(jwtOptions.ClockSkewMinutes)
|
||||
};
|
||||
});
|
||||
|
||||
services.AddScoped<IAdsPrincipalAccessor, AdsPrincipalAccessor>();
|
||||
|
||||
// NUEVO: servicio emisor de token (requiere que el host registre validadores)
|
||||
services.AddScoped<IAdsTokenService, AdsTokenService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAdsAuthorization(
|
||||
this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthorization();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Security.Claims;
|
||||
using ADS.Helpers.Auth.Abstractions;
|
||||
using ADS.Helpers.Auth.Claims;
|
||||
using ADS.Helpers.Auth.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ADS.Helpers.Auth.Implementation;
|
||||
|
||||
internal sealed class AdsPrincipalAccessor : IAdsPrincipalAccessor
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private AdsPrincipal? _cached;
|
||||
|
||||
public AdsPrincipalAccessor(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public bool IsAuthenticated =>
|
||||
_httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true;
|
||||
|
||||
public AdsPrincipal Current
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_cached is not null)
|
||||
return _cached;
|
||||
|
||||
var user = _httpContextAccessor.HttpContext?.User
|
||||
?? throw new UnauthorizedAccessException("No HttpContext user.");
|
||||
|
||||
_cached = Map(user);
|
||||
return _cached;
|
||||
}
|
||||
}
|
||||
|
||||
private static AdsPrincipal Map(ClaimsPrincipal user)
|
||||
{
|
||||
int ParseInt(string type) =>
|
||||
int.TryParse(user.FindFirst(type)?.Value, out var val) ? val : 0;
|
||||
|
||||
int[] ParseIntArray(string type) =>
|
||||
user.FindAll(type)
|
||||
.Select(x => int.TryParse(x.Value, out var v) ? v : 0)
|
||||
.Where(x => x > 0)
|
||||
.ToArray();
|
||||
|
||||
string[] ParseStringArray(string type) =>
|
||||
user.FindAll(type)
|
||||
.Select(x => x.Value)
|
||||
.ToArray();
|
||||
|
||||
return new AdsPrincipal
|
||||
{
|
||||
CveUser = ParseInt(AdsClaimTypes.CveUser),
|
||||
TenantId = AdsClaimTypes.TenantId,
|
||||
UserName = user.FindFirst(AdsClaimTypes.UserName)?.Value,
|
||||
CveRole = ParseInt(AdsClaimTypes.CveRole),
|
||||
CveEmpresa = ParseInt(AdsClaimTypes.CveEmpresa),
|
||||
CveCliente = ParseInt(AdsClaimTypes.CveCliente),
|
||||
CveModulos = ParseIntArray(AdsClaimTypes.CveModulos),
|
||||
Permissions = ParseStringArray(AdsClaimTypes.Permissions),
|
||||
Claims = user.Claims.ToDictionary(x => x.Type, x => x.Value)
|
||||
};
|
||||
}
|
||||
}
|
||||
158
Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs
Normal file
158
Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using ADS.Helpers.Auth.Abstractions;
|
||||
using ADS.Helpers.Auth.Claims;
|
||||
using ADS.Helpers.Auth.Models;
|
||||
using ADS.Helpers.Auth.Token;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace ADS.Helpers.Auth.Implementation;
|
||||
|
||||
internal sealed class AdsTokenService : IAdsTokenService
|
||||
{
|
||||
private readonly JwtOptions _options;
|
||||
private readonly IAdsUserCredentialValidator _userValidator;
|
||||
private readonly IAdsClientCredentialValidator _clientValidator;
|
||||
|
||||
public AdsTokenService(
|
||||
IOptions<JwtOptions> options,
|
||||
IAdsUserCredentialValidator userValidator,
|
||||
IAdsClientCredentialValidator clientValidator)
|
||||
{
|
||||
_options = options.Value ?? throw new InvalidOperationException("JwtOptions missing.");
|
||||
_userValidator = userValidator ?? throw new ArgumentNullException(nameof(userValidator));
|
||||
_clientValidator = clientValidator ?? throw new ArgumentNullException(nameof(clientValidator));
|
||||
}
|
||||
|
||||
public async Task<AdsTokenResult> IssueAsync(AdsTokenRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (request is null) throw new ArgumentNullException(nameof(request));
|
||||
if (string.IsNullOrWhiteSpace(request.GrantType)) throw new InvalidOperationException("GrantType is required.");
|
||||
|
||||
var grant = request.GrantType.Trim().ToLowerInvariant();
|
||||
|
||||
if (grant == AdsGrantTypes.Password)
|
||||
return await IssuePasswordAsync(request, ct);
|
||||
|
||||
if (grant == AdsGrantTypes.ClientCredentials)
|
||||
return await IssueClientCredentialsAsync(request, ct);
|
||||
|
||||
throw new InvalidOperationException($"Unsupported GrantType: {request.GrantType}");
|
||||
}
|
||||
|
||||
private async Task<AdsTokenResult> IssuePasswordAsync(AdsTokenRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||
throw new InvalidOperationException("UserName is required for password grant.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Password))
|
||||
throw new InvalidOperationException("Password is required for password grant.");
|
||||
|
||||
var validation = await _userValidator.ValidateAsync(
|
||||
request.UserName.Trim(),
|
||||
request.Password,
|
||||
request.TenantId,
|
||||
request.Scope,
|
||||
ct);
|
||||
|
||||
if (!validation.IsValid)
|
||||
throw new UnauthorizedAccessException(validation.Error ?? "Invalid credentials.");
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(AdsClaimTypes.CveUser, validation.CveUser.ToString()),
|
||||
new(AdsClaimTypes.UserName, validation.UserName ?? request.UserName.Trim()),
|
||||
new(AdsClaimTypes.CveRole, validation.CveRole.ToString()),
|
||||
new(AdsClaimTypes.CveEmpresa, validation.CveEmpresa.ToString()),
|
||||
new(AdsClaimTypes.CveCliente, validation.CveCliente.ToString()),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TenantId))
|
||||
claims.Add(new Claim(AdsClaimTypes.TenantId, request.TenantId));
|
||||
|
||||
foreach (var m in validation.CveModulos.Where(x => x > 0))
|
||||
claims.Add(new Claim(AdsClaimTypes.CveModulos, m.ToString()));
|
||||
|
||||
foreach (var p in validation.Permissions.Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
claims.Add(new Claim(AdsClaimTypes.Permissions, p));
|
||||
|
||||
if (validation.ExtraClaims.Count > 0)
|
||||
claims.AddRange(validation.ExtraClaims);
|
||||
|
||||
return BuildToken(claims, request.Scope);
|
||||
}
|
||||
|
||||
private async Task<AdsTokenResult> IssueClientCredentialsAsync(AdsTokenRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ClientId))
|
||||
throw new InvalidOperationException("ClientId is required for client_credentials grant.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ClientSecret))
|
||||
throw new InvalidOperationException("ClientSecret is required for client_credentials grant.");
|
||||
|
||||
var validation = await _clientValidator.ValidateAsync(
|
||||
request.ClientId.Trim(),
|
||||
request.ClientSecret,
|
||||
request.TenantId,
|
||||
request.Scope,
|
||||
ct);
|
||||
|
||||
if (!validation.IsValid)
|
||||
throw new UnauthorizedAccessException(validation.Error ?? "Invalid client credentials.");
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(AdsClaimTypes.UserName, validation.ClientId),
|
||||
};
|
||||
|
||||
var tenantId = validation.TenantId ?? request.TenantId;
|
||||
if (!string.IsNullOrWhiteSpace(request.TenantId))
|
||||
claims.Add(new Claim(AdsClaimTypes.TenantId, request.TenantId));
|
||||
|
||||
if (validation.ExtraClaims.Count > 0)
|
||||
claims.AddRange(validation.ExtraClaims);
|
||||
|
||||
return BuildToken(claims, request.Scope);
|
||||
}
|
||||
|
||||
private AdsTokenResult BuildToken(IReadOnlyCollection<Claim> claims, string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.SigningKey))
|
||||
throw new InvalidOperationException("JwtOptions.SigningKey is required.");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var expires = now.AddMinutes(_options.AccessTokenMinutes <= 0 ? 60 : _options.AccessTokenMinutes);
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
expires: expires,
|
||||
signingCredentials: creds
|
||||
);
|
||||
|
||||
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt);
|
||||
|
||||
return new AdsTokenResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
TokenType = "Bearer",
|
||||
ExpiresIn = (int)Math.Max(0, (expires - now).TotalSeconds),
|
||||
Scope = scope
|
||||
};
|
||||
}
|
||||
|
||||
public Task<AdsTokenResult> IssueFromClaimsAsync(IReadOnlyCollection<Claim> claims, string? scope = null, CancellationToken ct = default)
|
||||
{
|
||||
if (claims is null) throw new ArgumentNullException(nameof(claims));
|
||||
// ct no se usa, pero lo dejamos por consistencia
|
||||
return Task.FromResult(BuildToken(claims, scope));
|
||||
}
|
||||
|
||||
}
|
||||
22
Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs
Normal file
22
Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace ADS.Helpers.Auth.Models;
|
||||
|
||||
public sealed class AdsPrincipal
|
||||
{
|
||||
public int CveUser { get; init; }
|
||||
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
public string? UserName { get; init; }
|
||||
|
||||
public int CveRole { get; init; }
|
||||
|
||||
public int CveEmpresa { get; init; }
|
||||
|
||||
public int CveCliente { get; init; }
|
||||
|
||||
public int[] CveModulos { get; init; } = [];
|
||||
|
||||
public string[] Permissions { get; init; } = [];
|
||||
|
||||
public Dictionary<string, string> Claims { get; init; } = new();
|
||||
}
|
||||
11
Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs
Normal file
11
Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace ADS.Helpers.Auth.Models;
|
||||
|
||||
public sealed class JwtOptions
|
||||
{
|
||||
public string Issuer { get; set; } = default!;
|
||||
public string Audience { get; set; } = default!;
|
||||
public string SigningKey { get; set; } = default!;
|
||||
public bool ValidateLifetime { get; set; } = true;
|
||||
public int ClockSkewMinutes { get; set; } = 5;
|
||||
public int AccessTokenMinutes { get; set; } = 60;
|
||||
}
|
||||
26
Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs
Normal file
26
Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace ADS.Helpers.Auth.Token;
|
||||
|
||||
public static class AdsGrantTypes
|
||||
{
|
||||
public const string Password = "password";
|
||||
public const string ClientCredentials = "client_credentials";
|
||||
}
|
||||
|
||||
public sealed class AdsTokenRequest
|
||||
{
|
||||
public string GrantType { get; set; } = AdsGrantTypes.Password;
|
||||
|
||||
// password
|
||||
public string? UserName { get; set; }
|
||||
public string? Password { get; set; }
|
||||
|
||||
// client_credentials
|
||||
public string? ClientId { get; set; }
|
||||
public string? ClientSecret { get; set; }
|
||||
|
||||
// multitenant / contexto
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
// opcional: scope
|
||||
public string? Scope { get; set; }
|
||||
}
|
||||
9
Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs
Normal file
9
Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace ADS.Helpers.Auth.Token;
|
||||
|
||||
public sealed class AdsTokenResult
|
||||
{
|
||||
public string AccessToken { get; set; } = default!;
|
||||
public string TokenType { get; set; } = "Bearer";
|
||||
public int ExpiresIn { get; set; }
|
||||
public string? Scope { get; set; }
|
||||
}
|
||||
44
Helpers/ADS.Helpers.Auth/readme.md
Normal file
44
Helpers/ADS.Helpers.Auth/readme.md
Normal file
@@ -0,0 +1,44 @@
|
||||
9️⃣ appsettings.json ejemplo
|
||||
|
||||
"Jwt": {
|
||||
"Issuer": "ADS",
|
||||
"Audience": "ADS_APIS",
|
||||
"SigningKey": "SUPER_SECRET_KEY_CHANGE_THIS",
|
||||
"ValidateLifetime": true,
|
||||
"ClockSkewMinutes": 5
|
||||
}
|
||||
|
||||
|
||||
🔟 Cómo usarlo en el Host o API GPS
|
||||
|
||||
builder.Services.AddAdsAuthentication(builder.Configuration);
|
||||
builder.Services.AddAdsAuthorization();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
Y en cualquier controlador:
|
||||
|
||||
public class GpsController : ControllerBase
|
||||
{
|
||||
private readonly IAdsPrincipalAccessor _principal;
|
||||
|
||||
public GpsController(IAdsPrincipalAccessor principal)
|
||||
{
|
||||
_principal = principal;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Get()
|
||||
{
|
||||
var user = _principal.Current;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
user.CveUser,
|
||||
user.TenantId,
|
||||
user.CveEmpresa,
|
||||
user.CveModulos
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user