From d0c284d9c09c68033bdf5b5d683b0b088b25fd47 Mon Sep 17 00:00:00 2001 From: Jorge Luis caudillo esparza Date: Sat, 9 May 2026 16:36:57 -0700 Subject: [PATCH] First Commit --- Apis/ADS.Apis.Gps/ADS.Apis.Gps.csproj | 22 +++ Apis/ADS.Apis.Gps/ADS.Apis.Gps.http | 6 + .../Security/IAdsClientCredentialValidator.cs | 13 ++ .../Security/IAdsUserCredentialValidator.cs | 13 ++ .../Common/Crypto/ILegacyPasswordEncryptor.cs | 6 + .../Application/Common/Crypto/Md5Hex32.cs | 38 ++++ .../Contracts/Auth/ISicrepAuthService.cs | 10 + .../Contracts/Common/ApiResponse.cs | 20 ++ .../Contracts/Common/PagedQuery.cs | 11 ++ .../Application/Contracts/Common/RequestOf.cs | 12 ++ .../Dtos/Auth/SicrepAuthRequest.cs | 10 + .../Dtos/Auth/SicrepAuthResponse.cs | 7 + .../Units/IUnitLocationQueryService.cs | 13 ++ .../Units/UnitClientLocationResponse.cs | 12 ++ .../Units/UnitLastLocationResponse.cs | 12 ++ .../UseCases/Auth/SicrepAuthService.cs | 90 +++++++++ .../Sicrep/SicrepAuthController.cs | 117 ++++++++++++ .../Controllers/UnitsController.cs | 57 ++++++ .../Sicrep/SicrepUserValidationResult.cs | 11 ++ .../Sicrep/ISicrepUserValidationRepository.cs | 17 ++ .../Crypto/LegacyCryptoOptions.cs | 8 + .../Crypto/LegacyPasswordEncryptor.cs | 69 +++++++ .../DapperSicrepUserValidationRepository.cs | 57 ++++++ .../ServiceCollectionExtensions.cs | 34 ++++ .../Security/AdsClientCredentialValidator.cs | 45 +++++ .../Security/AdsUserCredentialValidator.cs | 47 +++++ .../Units/UnitLocationQueryService.cs | 60 ++++++ Apis/ADS.Apis.Gps/Program.cs | 49 +++++ .../Properties/launchSettings.json | 23 +++ Apis/ADS.Apis.Gps/appsettings.json | 27 +++ Apis/ADS.Apis.Gps/readmegpt | 30 +++ Apis/ADS.Apis.slnx | 11 ++ Apis/ADS.Host/ADS.Host.csproj | 27 +++ Apis/ADS.Host/ADS.Host.http | 6 + .../Auth/HostAdsUserCredentialValidator .cs | 142 ++++++++++++++ .../Auth/StubAdsClientCredentialValidator.cs | 22 +++ .../Auth/StubAdsUserCredentialValidator.cs | 20 ++ Apis/ADS.Host/Controllers/AuthController.cs | 29 +++ Apis/ADS.Host/Controllers/WhoAmIController.cs | 36 ++++ Apis/ADS.Host/Program.cs | 125 ++++++++++++ Apis/ADS.Host/Properties/launchSettings.json | 23 +++ Apis/ADS.Host/appsettings.json | 62 ++++++ .../ADS.Helpers.Auth/ADS.Helpers.Auth.csproj | 17 ++ .../IAdsClientCredentialValidator.cs | 27 +++ .../Abstractions/IAdsPrincipalAccessor.cs | 9 + .../Abstractions/IAdsTokenService.cs | 13 ++ .../IAdsUserCredentialValidator.cs | 37 ++++ .../ADS.Helpers.Auth/Claims/AdsClaimTypes.cs | 13 ++ .../Contracts/Sicrep/Auth/TokenRequest.cs | 8 + .../Contracts/Sicrep/Auth/TokenResponse.cs | 9 + .../Extensions/ServiceCollectionExtensions.cs | 59 ++++++ .../Implementation/AdsPrincipalAccessor.cs | 66 +++++++ .../Implementation/AdsTokenService.cs | 158 ++++++++++++++++ .../ADS.Helpers.Auth/Models/AdsPrincipal.cs | 22 +++ Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs | 11 ++ .../ADS.Helpers.Auth/Token/AdsTokenRequest.cs | 26 +++ .../ADS.Helpers.Auth/Token/AdsTokenResult.cs | 9 + Helpers/ADS.Helpers.Auth/readme.md | 44 +++++ .../ADS.Helpers.Base/ADS.Helpers.Base.csproj | 9 + .../Abstractions/IAppLogger.cs | 8 + Helpers/ADS.Helpers.Base/Class1.cs | 6 + Helpers/ADS.Helpers.Base/readme.md | 9 + .../ADS.Helpers.Data/ADS.Helpers.Data.csproj | 18 ++ .../ITenantDbConnectionFactory.cs | 8 + .../Extensions/ServiceCollectionExtensions.cs | 13 ++ .../TenantDbConnectionFactory.cs | 26 +++ Helpers/ADS.Helpers.Data/readme.md | 15 ++ .../ADS.Helpers.Localization.csproj | 17 ++ .../Abstractions/IMessageCatalog.cs | 6 + Helpers/ADS.Helpers.Localization/Class1.cs | 6 + .../Extensions/ServiceCollectionExtensions.cs | 14 ++ .../InMemoryMessageCatalog.cs | 28 +++ .../ADS.Helpers.Middleware.csproj | 22 +++ .../ADSException/AdsExceptionMiddleware.cs | 82 ++++++++ .../ADSException/FileAdsErrorLogger.cs | 24 +++ .../ADSException/IAdsErrorLogger.cs | 24 +++ .../ADSException/LoggingOptions.cs | 6 + .../Extensions/AdsApiResponseExtensions.cs | 34 ++++ .../Extensions/AdsExtensions.cs | 76 ++++++++ .../Http/ApiResponse.cs | 20 ++ .../Http/ApiResponseWrapFilter.cs | 62 ++++++ .../ADS.Helpers.Middleware/Http/PagedQuery.cs | 9 + .../ADS.Helpers.Middleware/Http/RequestOf.cs | 11 ++ .../IInternalUserContextAccessor.cs | 6 + .../InternalAuthorizeAttribute.cs | 26 +++ .../InternalUser/InternalUserContext.cs | 8 + .../InternalUserContextAccessor.cs | 6 + .../InternalUserContextMiddleware.cs | 67 +++++++ .../Logging/DefaultAppLogger.cs | 24 +++ .../Tenant/TenantMiddleware.cs | 57 ++++++ .../ADS.Helpers.MultiTenant.csproj | 20 ++ .../Abstractions/ITenantResolver.cs | 8 + .../Abstractions/ITenantStore.cs | 9 + .../Configuration/TenantOptions.cs | 11 ++ .../Context/ITenantContextAccessor.cs | 7 + .../Context/InMemoryTenantStore .cs | 17 ++ .../Context/TenantContext.cs | 22 +++ .../Context/TenantContextAccessor.cs | 12 ++ .../Data/IDbConnectionFactory.cs | 8 + .../Data/TenantDbConnectionFactory.cs | 25 +++ .../ApplicationBuilderExtensions.cs | 10 + .../ITenantConnectionStringProvider.cs | 0 .../InternalRouting/InternalHeaders.cs | 24 +++ .../InternalRouting/InternalRoutingOptions.cs | 21 ++ .../InternalRouting/InternalSignature.cs | 43 +++++ .../InternalTenantDbHeaderWriter.cs | 46 +++++ .../InternalTenantDbHeaders.cs | 7 + .../InternalTenantDbMiddleware.cs | 134 +++++++++++++ .../ServiceCollectionExtensions.cs | 29 +++ .../TenantConnectionStringProvider.cs | 0 .../Resolvers/HeaderTenantResolver.cs | 25 +++ .../Stores/ConfigurationTenantStore.cs | 22 +++ .../ADS.Helpers.NotifyCenter.csproj | 22 +++ .../Abstractions/INotificationProvider.cs | 13 ++ .../INotificationSettingsProvider.cs | 10 + .../Abstractions/INotifyCenter.cs | 10 + .../Extensions/ServiceCollectionExtensions.cs | 34 ++++ .../Models/Localization/MessageSources.cs | 7 + .../Models/Localization/NotifyCodes.cs | 13 ++ .../Models/NotificationChannel.cs | 11 ++ .../Models/NotificationMessage.cs | 26 +++ .../Models/NotificationResult.cs | 57 ++++++ .../Models/NotifyCenterOptions.cs | 10 + .../ADS.Helpers.NotifyCenter/NotifyCenter.cs | 179 ++++++++++++++++++ .../InMemoryNotificationSettingsProvider.cs | 33 ++++ .../Smtp/SmtpNotificationProvider.cs | 139 ++++++++++++++ .../Providers/Smtp/SmtpOptions.cs | 19 ++ .../Providers/Smtp/SmtpSettings.cs | 18 ++ Helpers/ADS.Helpers.sln | 137 ++++++++++++++ .../ADS.Helpers.Tests.csproj | 37 ++++ Helpers/tests/ADS.Helpers.Tests/UnitTest1.cs | 10 + 131 files changed, 4009 insertions(+) create mode 100644 Apis/ADS.Apis.Gps/ADS.Apis.Gps.csproj create mode 100644 Apis/ADS.Apis.Gps/ADS.Apis.Gps.http create mode 100644 Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsClientCredentialValidator.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsUserCredentialValidator.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Common/Crypto/ILegacyPasswordEncryptor.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Common/Crypto/Md5Hex32.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Contracts/Auth/ISicrepAuthService.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Contracts/Common/ApiResponse.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Contracts/Common/PagedQuery.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Contracts/Common/RequestOf.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthRequest.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthResponse.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Units/IUnitLocationQueryService.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Units/UnitClientLocationResponse.cs create mode 100644 Apis/ADS.Apis.Gps/Application/Units/UnitLastLocationResponse.cs create mode 100644 Apis/ADS.Apis.Gps/Application/UseCases/Auth/SicrepAuthService.cs create mode 100644 Apis/ADS.Apis.Gps/Controllers/Sicrep/SicrepAuthController.cs create mode 100644 Apis/ADS.Apis.Gps/Controllers/UnitsController.cs create mode 100644 Apis/ADS.Apis.Gps/Domain/Models/Sicrep/SicrepUserValidationResult.cs create mode 100644 Apis/ADS.Apis.Gps/Domain/Ports/Sicrep/ISicrepUserValidationRepository.cs create mode 100644 Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyCryptoOptions.cs create mode 100644 Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyPasswordEncryptor.cs create mode 100644 Apis/ADS.Apis.Gps/Infrastructure/Data/Dapper/DapperSicrepUserValidationRepository.cs create mode 100644 Apis/ADS.Apis.Gps/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 Apis/ADS.Apis.Gps/Infrastructure/Security/AdsClientCredentialValidator.cs create mode 100644 Apis/ADS.Apis.Gps/Infrastructure/Security/AdsUserCredentialValidator.cs create mode 100644 Apis/ADS.Apis.Gps/Infrastructure/Units/UnitLocationQueryService.cs create mode 100644 Apis/ADS.Apis.Gps/Program.cs create mode 100644 Apis/ADS.Apis.Gps/Properties/launchSettings.json create mode 100644 Apis/ADS.Apis.Gps/appsettings.json create mode 100644 Apis/ADS.Apis.Gps/readmegpt create mode 100644 Apis/ADS.Apis.slnx create mode 100644 Apis/ADS.Host/ADS.Host.csproj create mode 100644 Apis/ADS.Host/ADS.Host.http create mode 100644 Apis/ADS.Host/Auth/HostAdsUserCredentialValidator .cs create mode 100644 Apis/ADS.Host/Auth/StubAdsClientCredentialValidator.cs create mode 100644 Apis/ADS.Host/Auth/StubAdsUserCredentialValidator.cs create mode 100644 Apis/ADS.Host/Controllers/AuthController.cs create mode 100644 Apis/ADS.Host/Controllers/WhoAmIController.cs create mode 100644 Apis/ADS.Host/Program.cs create mode 100644 Apis/ADS.Host/Properties/launchSettings.json create mode 100644 Apis/ADS.Host/appsettings.json create mode 100644 Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj create mode 100644 Helpers/ADS.Helpers.Auth/Abstractions/IAdsClientCredentialValidator.cs create mode 100644 Helpers/ADS.Helpers.Auth/Abstractions/IAdsPrincipalAccessor.cs create mode 100644 Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs create mode 100644 Helpers/ADS.Helpers.Auth/Abstractions/IAdsUserCredentialValidator.cs create mode 100644 Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs create mode 100644 Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenRequest.cs create mode 100644 Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenResponse.cs create mode 100644 Helpers/ADS.Helpers.Auth/Extensions/ServiceCollectionExtensions.cs create mode 100644 Helpers/ADS.Helpers.Auth/Implementation/AdsPrincipalAccessor.cs create mode 100644 Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs create mode 100644 Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs create mode 100644 Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs create mode 100644 Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs create mode 100644 Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs create mode 100644 Helpers/ADS.Helpers.Auth/readme.md create mode 100644 Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj create mode 100644 Helpers/ADS.Helpers.Base/Abstractions/IAppLogger.cs create mode 100644 Helpers/ADS.Helpers.Base/Class1.cs create mode 100644 Helpers/ADS.Helpers.Base/readme.md create mode 100644 Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj create mode 100644 Helpers/ADS.Helpers.Data/Abstractions/ITenantDbConnectionFactory.cs create mode 100644 Helpers/ADS.Helpers.Data/Extensions/ServiceCollectionExtensions.cs create mode 100644 Helpers/ADS.Helpers.Data/TenantDbConnectionFactory.cs create mode 100644 Helpers/ADS.Helpers.Data/readme.md create mode 100644 Helpers/ADS.Helpers.Localization/ADS.Helpers.Localization.csproj create mode 100644 Helpers/ADS.Helpers.Localization/Abstractions/IMessageCatalog.cs create mode 100644 Helpers/ADS.Helpers.Localization/Class1.cs create mode 100644 Helpers/ADS.Helpers.Localization/Extensions/ServiceCollectionExtensions.cs create mode 100644 Helpers/ADS.Helpers.Localization/InMemoryMessageCatalog.cs create mode 100644 Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj create mode 100644 Helpers/ADS.Helpers.Middleware/ADSException/AdsExceptionMiddleware.cs create mode 100644 Helpers/ADS.Helpers.Middleware/ADSException/FileAdsErrorLogger.cs create mode 100644 Helpers/ADS.Helpers.Middleware/ADSException/IAdsErrorLogger.cs create mode 100644 Helpers/ADS.Helpers.Middleware/ADSException/LoggingOptions.cs create mode 100644 Helpers/ADS.Helpers.Middleware/Extensions/AdsApiResponseExtensions.cs create mode 100644 Helpers/ADS.Helpers.Middleware/Extensions/AdsExtensions.cs create mode 100644 Helpers/ADS.Helpers.Middleware/Http/ApiResponse.cs create mode 100644 Helpers/ADS.Helpers.Middleware/Http/ApiResponseWrapFilter.cs create mode 100644 Helpers/ADS.Helpers.Middleware/Http/PagedQuery.cs create mode 100644 Helpers/ADS.Helpers.Middleware/Http/RequestOf.cs create mode 100644 Helpers/ADS.Helpers.Middleware/InternalUser/IInternalUserContextAccessor.cs create mode 100644 Helpers/ADS.Helpers.Middleware/InternalUser/InternalAuthorizeAttribute.cs create mode 100644 Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContext.cs create mode 100644 Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContextAccessor.cs create mode 100644 Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContextMiddleware.cs create mode 100644 Helpers/ADS.Helpers.Middleware/Logging/DefaultAppLogger.cs create mode 100644 Helpers/ADS.Helpers.Middleware/Tenant/TenantMiddleware.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/ADS.Helpers.MultiTenant.csproj create mode 100644 Helpers/ADS.Helpers.MultiTenant/Abstractions/ITenantResolver.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/Abstractions/ITenantStore.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/Configuration/TenantOptions.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/Context/ITenantContextAccessor.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/Context/InMemoryTenantStore .cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/Context/TenantContext.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/Context/TenantContextAccessor.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/Data/IDbConnectionFactory.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/Data/TenantDbConnectionFactory.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/InternalRouting/ApplicationBuilderExtensions.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/InternalRouting/ITenantConnectionStringProvider.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalHeaders.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalRoutingOptions.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalSignature.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbHeaderWriter.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbHeaders.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbMiddleware.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/InternalRouting/ServiceCollectionExtensions.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/InternalRouting/TenantConnectionStringProvider.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/Resolvers/HeaderTenantResolver.cs create mode 100644 Helpers/ADS.Helpers.MultiTenant/Stores/ConfigurationTenantStore.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/ADS.Helpers.NotifyCenter.csproj create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotificationProvider.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotificationSettingsProvider.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotifyCenter.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Extensions/ServiceCollectionExtensions.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Models/Localization/MessageSources.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Models/Localization/NotifyCodes.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Models/NotificationChannel.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Models/NotificationMessage.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Models/NotificationResult.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Models/NotifyCenterOptions.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/NotifyCenter.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Providers/InMemoryNotificationSettingsProvider.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpNotificationProvider.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpOptions.cs create mode 100644 Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpSettings.cs create mode 100644 Helpers/ADS.Helpers.sln create mode 100644 Helpers/tests/ADS.Helpers.Tests/ADS.Helpers.Tests.csproj create mode 100644 Helpers/tests/ADS.Helpers.Tests/UnitTest1.cs diff --git a/Apis/ADS.Apis.Gps/ADS.Apis.Gps.csproj b/Apis/ADS.Apis.Gps/ADS.Apis.Gps.csproj new file mode 100644 index 0000000..a633c7b --- /dev/null +++ b/Apis/ADS.Apis.Gps/ADS.Apis.Gps.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/Apis/ADS.Apis.Gps/ADS.Apis.Gps.http b/Apis/ADS.Apis.Gps/ADS.Apis.Gps.http new file mode 100644 index 0000000..6c806bb --- /dev/null +++ b/Apis/ADS.Apis.Gps/ADS.Apis.Gps.http @@ -0,0 +1,6 @@ +@ADS.Apis.Gps_HostAddress = http://localhost:5286 + +GET {{ADS.Apis.Gps_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsClientCredentialValidator.cs b/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsClientCredentialValidator.cs new file mode 100644 index 0000000..28652dc --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsClientCredentialValidator.cs @@ -0,0 +1,13 @@ +namespace ADS.Apis.Gps.Application.Abstractions.Security; + +public interface IAdsClientCredentialValidator +{ + Task ValidateAsync(string clientId, string clientSecret, CancellationToken ct = default); +} + +public sealed record AdsClientCredentialResult( + bool IsValid, + int? ClientId, + string? ClientKey, + string? ClientName +); \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsUserCredentialValidator.cs b/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsUserCredentialValidator.cs new file mode 100644 index 0000000..3b57a2b --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsUserCredentialValidator.cs @@ -0,0 +1,13 @@ +namespace ADS.Apis.Gps.Application.Abstractions.Security; + +public interface IAdsUserCredentialValidator +{ + Task ValidateAsync(string username, string password, CancellationToken ct = default); +} + +public sealed record AdsUserCredentialResult( + bool IsValid, + int? UserId, + string? Username, + string? DisplayName +); \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Common/Crypto/ILegacyPasswordEncryptor.cs b/Apis/ADS.Apis.Gps/Application/Common/Crypto/ILegacyPasswordEncryptor.cs new file mode 100644 index 0000000..b27b784 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Common/Crypto/ILegacyPasswordEncryptor.cs @@ -0,0 +1,6 @@ +namespace ADS.Apis.Gps.Application.Common.Crypto; + +public interface ILegacyPasswordEncryptor +{ + string Encrypt(string plainText); +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Common/Crypto/Md5Hex32.cs b/Apis/ADS.Apis.Gps/Application/Common/Crypto/Md5Hex32.cs new file mode 100644 index 0000000..0b1ad66 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Common/Crypto/Md5Hex32.cs @@ -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(); + } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Contracts/Auth/ISicrepAuthService.cs b/Apis/ADS.Apis.Gps/Application/Contracts/Auth/ISicrepAuthService.cs new file mode 100644 index 0000000..1db5fc2 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Contracts/Auth/ISicrepAuthService.cs @@ -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 CreateTokenAsync(SicrepAuthRequest request, string? ip, string? mac, string? grupoEmpresa, CancellationToken ct = default); +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Contracts/Common/ApiResponse.cs b/Apis/ADS.Apis.Gps/Application/Contracts/Common/ApiResponse.cs new file mode 100644 index 0000000..2c87706 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Contracts/Common/ApiResponse.cs @@ -0,0 +1,20 @@ +namespace ADS.Apis.Gps.Contracts.Common; + +public sealed class ApiResponse +{ + 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 +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Contracts/Common/PagedQuery.cs b/Apis/ADS.Apis.Gps/Application/Contracts/Common/PagedQuery.cs new file mode 100644 index 0000000..001b668 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Contracts/Common/PagedQuery.cs @@ -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; +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Contracts/Common/RequestOf.cs b/Apis/ADS.Apis.Gps/Application/Contracts/Common/RequestOf.cs new file mode 100644 index 0000000..67e2318 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Contracts/Common/RequestOf.cs @@ -0,0 +1,12 @@ +namespace ADS.Apis.Gps.Contracts.Common; + +public sealed class RequestOf +{ + 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; +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthRequest.cs b/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthRequest.cs new file mode 100644 index 0000000..f7237b9 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthRequest.cs @@ -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; } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthResponse.cs b/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthResponse.cs new file mode 100644 index 0000000..423286f --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthResponse.cs @@ -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"; +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Units/IUnitLocationQueryService.cs b/Apis/ADS.Apis.Gps/Application/Units/IUnitLocationQueryService.cs new file mode 100644 index 0000000..2287602 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Units/IUnitLocationQueryService.cs @@ -0,0 +1,13 @@ +namespace ADS.Apis.Gps.Application.Units; + +public interface IUnitLocationQueryService +{ + Task GetLastLocationByNoEconomicoAsync( + int cveUsuario, + string noEconomico, + CancellationToken ct = default); + Task> GetLocationsByClientAsync( + int cveUsuario, + int cveCliente, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Units/UnitClientLocationResponse.cs b/Apis/ADS.Apis.Gps/Application/Units/UnitClientLocationResponse.cs new file mode 100644 index 0000000..e1963a1 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Units/UnitClientLocationResponse.cs @@ -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; } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/Units/UnitLastLocationResponse.cs b/Apis/ADS.Apis.Gps/Application/Units/UnitLastLocationResponse.cs new file mode 100644 index 0000000..6e96d7b --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/Units/UnitLastLocationResponse.cs @@ -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; } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Application/UseCases/Auth/SicrepAuthService.cs b/Apis/ADS.Apis.Gps/Application/UseCases/Auth/SicrepAuthService.cs new file mode 100644 index 0000000..4900276 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Application/UseCases/Auth/SicrepAuthService.cs @@ -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 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 + { + 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 + }; + } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Controllers/Sicrep/SicrepAuthController.cs b/Apis/ADS.Apis.Gps/Controllers/Sicrep/SicrepAuthController.cs new file mode 100644 index 0000000..13968b1 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Controllers/Sicrep/SicrepAuthController.cs @@ -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> 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), StatusCodes.Status200OK)] + // public Task> 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> 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> 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 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 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> 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 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> GetHealth([FromRoute] int unitId, CancellationToken ct = default) + // { + // var dto = await _svc.GetHealthAsync(unitId, ct); + // return dto is null ? NotFound() : Ok(dto); + // } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Controllers/UnitsController.cs b/Apis/ADS.Apis.Gps/Controllers/UnitsController.cs new file mode 100644 index 0000000..3182fad --- /dev/null +++ b/Apis/ADS.Apis.Gps/Controllers/UnitsController.cs @@ -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 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 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); + } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Domain/Models/Sicrep/SicrepUserValidationResult.cs b/Apis/ADS.Apis.Gps/Domain/Models/Sicrep/SicrepUserValidationResult.cs new file mode 100644 index 0000000..f1ed300 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Domain/Models/Sicrep/SicrepUserValidationResult.cs @@ -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.) +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Domain/Ports/Sicrep/ISicrepUserValidationRepository.cs b/Apis/ADS.Apis.Gps/Domain/Ports/Sicrep/ISicrepUserValidationRepository.cs new file mode 100644 index 0000000..0adf804 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Domain/Ports/Sicrep/ISicrepUserValidationRepository.cs @@ -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 ValidateUserAsync( + string usuario, + string contrasena32, + int cveEmpresa, + string? ip, + string? mac, + string? grupoEmpresa, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyCryptoOptions.cs b/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyCryptoOptions.cs new file mode 100644 index 0000000..86b7a68 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyCryptoOptions.cs @@ -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 +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyPasswordEncryptor.cs b/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyPasswordEncryptor.cs new file mode 100644 index 0000000..ff3dcea --- /dev/null +++ b/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyPasswordEncryptor.cs @@ -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 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); + } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Data/Dapper/DapperSicrepUserValidationRepository.cs b/Apis/ADS.Apis.Gps/Infrastructure/Data/Dapper/DapperSicrepUserValidationRepository.cs new file mode 100644 index 0000000..57e39f7 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Infrastructure/Data/Dapper/DapperSicrepUserValidationRepository.cs @@ -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 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( + sp, + p, + commandType: CommandType.StoredProcedure); + + return result; + } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/Apis/ADS.Apis.Gps/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..04d3734 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -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(); + services.AddScoped(); + // Domain Ports -> Infrastructure + services.AddScoped(); + + // Application + services.AddScoped(); + + return services; + } + public static IServiceCollection AddGpsInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("LegacyCrypto")); + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsClientCredentialValidator.cs b/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsClientCredentialValidator.cs new file mode 100644 index 0000000..8e906fa --- /dev/null +++ b/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsClientCredentialValidator.cs @@ -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 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( + // 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; + } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsUserCredentialValidator.cs b/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsUserCredentialValidator.cs new file mode 100644 index 0000000..38db1bc --- /dev/null +++ b/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsUserCredentialValidator.cs @@ -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 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( + // 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; + } +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Units/UnitLocationQueryService.cs b/Apis/ADS.Apis.Gps/Infrastructure/Units/UnitLocationQueryService.cs new file mode 100644 index 0000000..9c101b5 --- /dev/null +++ b/Apis/ADS.Apis.Gps/Infrastructure/Units/UnitLocationQueryService.cs @@ -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 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(command); + } + public async Task> 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(command); + + return result.AsList(); +} +} \ No newline at end of file diff --git a/Apis/ADS.Apis.Gps/Program.cs b/Apis/ADS.Apis.Gps/Program.cs new file mode 100644 index 0000000..9f370da --- /dev/null +++ b/Apis/ADS.Apis.Gps/Program.cs @@ -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(); +builder.Services.AddScoped(); + +// ✅ 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(); diff --git a/Apis/ADS.Apis.Gps/Properties/launchSettings.json b/Apis/ADS.Apis.Gps/Properties/launchSettings.json new file mode 100644 index 0000000..f9c2f2c --- /dev/null +++ b/Apis/ADS.Apis.Gps/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/Apis/ADS.Apis.Gps/appsettings.json b/Apis/ADS.Apis.Gps/appsettings.json new file mode 100644 index 0000000..e6d1e4f --- /dev/null +++ b/Apis/ADS.Apis.Gps/appsettings.json @@ -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": "*" +} diff --git a/Apis/ADS.Apis.Gps/readmegpt b/Apis/ADS.Apis.Gps/readmegpt new file mode 100644 index 0000000..97bb16d --- /dev/null +++ b/Apis/ADS.Apis.Gps/readmegpt @@ -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 \ No newline at end of file diff --git a/Apis/ADS.Apis.slnx b/Apis/ADS.Apis.slnx new file mode 100644 index 0000000..80fb2da --- /dev/null +++ b/Apis/ADS.Apis.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Apis/ADS.Host/ADS.Host.csproj b/Apis/ADS.Host/ADS.Host.csproj new file mode 100644 index 0000000..85bb901 --- /dev/null +++ b/Apis/ADS.Host/ADS.Host.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + diff --git a/Apis/ADS.Host/ADS.Host.http b/Apis/ADS.Host/ADS.Host.http new file mode 100644 index 0000000..3da3666 --- /dev/null +++ b/Apis/ADS.Host/ADS.Host.http @@ -0,0 +1,6 @@ +@ADS.Host_HostAddress = http://localhost:5030 + +GET {{ADS.Host_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Apis/ADS.Host/Auth/HostAdsUserCredentialValidator .cs b/Apis/ADS.Host/Auth/HostAdsUserCredentialValidator .cs new file mode 100644 index 0000000..035b451 --- /dev/null +++ b/Apis/ADS.Host/Auth/HostAdsUserCredentialValidator .cs @@ -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 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( + "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; } + } +} \ No newline at end of file diff --git a/Apis/ADS.Host/Auth/StubAdsClientCredentialValidator.cs b/Apis/ADS.Host/Auth/StubAdsClientCredentialValidator.cs new file mode 100644 index 0000000..47c983f --- /dev/null +++ b/Apis/ADS.Host/Auth/StubAdsClientCredentialValidator.cs @@ -0,0 +1,22 @@ +using ADS.Helpers.Auth.Abstractions; + +namespace ADS.Host.Auth; + +public sealed class StubAdsClientCredentialValidator : IAdsClientCredentialValidator +{ + public Task 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." + }); + } +} \ No newline at end of file diff --git a/Apis/ADS.Host/Auth/StubAdsUserCredentialValidator.cs b/Apis/ADS.Host/Auth/StubAdsUserCredentialValidator.cs new file mode 100644 index 0000000..8de35a6 --- /dev/null +++ b/Apis/ADS.Host/Auth/StubAdsUserCredentialValidator.cs @@ -0,0 +1,20 @@ +using ADS.Helpers.Auth.Abstractions; + +namespace ADS.Host.Auth; + +public sealed class StubAdsUserCredentialValidator : IAdsUserCredentialValidator +{ + public Task 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." + }); + } +} \ No newline at end of file diff --git a/Apis/ADS.Host/Controllers/AuthController.cs b/Apis/ADS.Host/Controllers/AuthController.cs new file mode 100644 index 0000000..98845a3 --- /dev/null +++ b/Apis/ADS.Host/Controllers/AuthController.cs @@ -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 Token( + [FromBody] AdsTokenRequest request, + CancellationToken ct) + { + + var token = await _tokenService.IssueAsync(request,ct); + + return Ok(token); + } +} \ No newline at end of file diff --git a/Apis/ADS.Host/Controllers/WhoAmIController.cs b/Apis/ADS.Host/Controllers/WhoAmIController.cs new file mode 100644 index 0000000..fa5d005 --- /dev/null +++ b/Apis/ADS.Host/Controllers/WhoAmIController.cs @@ -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 + }); + } +} diff --git a/Apis/ADS.Host/Program.cs b/Apis/ADS.Host/Program.cs new file mode 100644 index 0000000..d771a9e --- /dev/null +++ b/Apis/ADS.Host/Program.cs @@ -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(); + var writer = http.RequestServices.GetRequiredService(); + + 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(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()["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(); +builder.Services.AddScoped(); +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(); diff --git a/Apis/ADS.Host/Properties/launchSettings.json b/Apis/ADS.Host/Properties/launchSettings.json new file mode 100644 index 0000000..7b9df13 --- /dev/null +++ b/Apis/ADS.Host/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/Apis/ADS.Host/appsettings.json b/Apis/ADS.Host/appsettings.json new file mode 100644 index 0000000..bd055e7 --- /dev/null +++ b/Apis/ADS.Host/appsettings.json @@ -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 + } +} diff --git a/Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj b/Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj new file mode 100644 index 0000000..7b69d0a --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/Helpers/ADS.Helpers.Auth/Abstractions/IAdsClientCredentialValidator.cs b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsClientCredentialValidator.cs new file mode 100644 index 0000000..81fbfd3 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsClientCredentialValidator.cs @@ -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 ExtraClaims { get; set; } = new(); + + public string? Error { get; set; } +} + +public interface IAdsClientCredentialValidator +{ + Task ValidateAsync( + string clientId, + string clientSecret, + string? tenantId, + string? scope, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Auth/Abstractions/IAdsPrincipalAccessor.cs b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsPrincipalAccessor.cs new file mode 100644 index 0000000..4ca9e93 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsPrincipalAccessor.cs @@ -0,0 +1,9 @@ +using ADS.Helpers.Auth.Models; + +namespace ADS.Helpers.Auth.Abstractions; + +public interface IAdsPrincipalAccessor +{ + AdsPrincipal Current { get; } + bool IsAuthenticated { get; } +} diff --git a/Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs new file mode 100644 index 0000000..1087f62 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs @@ -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 IssueAsync(AdsTokenRequest request, CancellationToken ct = default); + + Task IssueFromClaimsAsync(IReadOnlyCollection claims, string? scope = null, CancellationToken ct = default); +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Auth/Abstractions/IAdsUserCredentialValidator.cs b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsUserCredentialValidator.cs new file mode 100644 index 0000000..c80f248 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsUserCredentialValidator.cs @@ -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 ExtraClaims { get; set; } = new(); + + public string? Error { get; set; } + public string? TenantId { get; set; } + + +} + +public interface IAdsUserCredentialValidator +{ + Task ValidateAsync( + string userName, + string password, + string? tenantId, + string? scope, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs b/Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs new file mode 100644 index 0000000..6cb8a9e --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs @@ -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"; +} diff --git a/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenRequest.cs b/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenRequest.cs new file mode 100644 index 0000000..ad0c508 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenRequest.cs @@ -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; } +} diff --git a/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenResponse.cs b/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenResponse.cs new file mode 100644 index 0000000..84bfda2 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenResponse.cs @@ -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; } +} diff --git a/Helpers/ADS.Helpers.Auth/Extensions/ServiceCollectionExtensions.cs b/Helpers/ADS.Helpers.Auth/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..de23dc5 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Extensions/ServiceCollectionExtensions.cs @@ -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(jwtSection); + + var jwtOptions = jwtSection.Get() + ?? 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(); + + // NUEVO: servicio emisor de token (requiere que el host registre validadores) + services.AddScoped(); + + return services; + } + + public static IServiceCollection AddAdsAuthorization( + this IServiceCollection services) + { + services.AddAuthorization(); + return services; + } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Auth/Implementation/AdsPrincipalAccessor.cs b/Helpers/ADS.Helpers.Auth/Implementation/AdsPrincipalAccessor.cs new file mode 100644 index 0000000..5b3b4a4 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Implementation/AdsPrincipalAccessor.cs @@ -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) + }; + } +} diff --git a/Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs b/Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs new file mode 100644 index 0000000..e09748d --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs @@ -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 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 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 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 + { + 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 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 + { + 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 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 IssueFromClaimsAsync(IReadOnlyCollection 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)); + } + +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs b/Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs new file mode 100644 index 0000000..c20e083 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs @@ -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 Claims { get; init; } = new(); +} diff --git a/Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs b/Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs new file mode 100644 index 0000000..2e3fc48 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs @@ -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; +} diff --git a/Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs b/Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs new file mode 100644 index 0000000..47b4afe --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs @@ -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; } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs b/Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs new file mode 100644 index 0000000..31aa6b7 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs @@ -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; } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Auth/readme.md b/Helpers/ADS.Helpers.Auth/readme.md new file mode 100644 index 0000000..652d595 --- /dev/null +++ b/Helpers/ADS.Helpers.Auth/readme.md @@ -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 + }); + } +} diff --git a/Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj b/Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Helpers/ADS.Helpers.Base/Abstractions/IAppLogger.cs b/Helpers/ADS.Helpers.Base/Abstractions/IAppLogger.cs new file mode 100644 index 0000000..6bf89ac --- /dev/null +++ b/Helpers/ADS.Helpers.Base/Abstractions/IAppLogger.cs @@ -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); +} diff --git a/Helpers/ADS.Helpers.Base/Class1.cs b/Helpers/ADS.Helpers.Base/Class1.cs new file mode 100644 index 0000000..6fe6a75 --- /dev/null +++ b/Helpers/ADS.Helpers.Base/Class1.cs @@ -0,0 +1,6 @@ +namespace ADS.Helpers.Base; + +public class Class1 +{ + +} diff --git a/Helpers/ADS.Helpers.Base/readme.md b/Helpers/ADS.Helpers.Base/readme.md new file mode 100644 index 0000000..4869a1f --- /dev/null +++ b/Helpers/ADS.Helpers.Base/readme.md @@ -0,0 +1,9 @@ +✅ 4️⃣ En tu API (Host) + +En Program.cs: + +builder.Services.AddAdsLogging(); +builder.Services.AddAdsNotifyCenter(); + + +Y listo. \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj b/Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj new file mode 100644 index 0000000..34cddc8 --- /dev/null +++ b/Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/Helpers/ADS.Helpers.Data/Abstractions/ITenantDbConnectionFactory.cs b/Helpers/ADS.Helpers.Data/Abstractions/ITenantDbConnectionFactory.cs new file mode 100644 index 0000000..7627946 --- /dev/null +++ b/Helpers/ADS.Helpers.Data/Abstractions/ITenantDbConnectionFactory.cs @@ -0,0 +1,8 @@ +using System.Data; + +namespace ADS.Helpers.Data.Abstractions; + +public interface ITenantDbConnectionFactory +{ + IDbConnection Create(); +} diff --git a/Helpers/ADS.Helpers.Data/Extensions/ServiceCollectionExtensions.cs b/Helpers/ADS.Helpers.Data/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0808a2a --- /dev/null +++ b/Helpers/ADS.Helpers.Data/Extensions/ServiceCollectionExtensions.cs @@ -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(); + return services; + } +} diff --git a/Helpers/ADS.Helpers.Data/TenantDbConnectionFactory.cs b/Helpers/ADS.Helpers.Data/TenantDbConnectionFactory.cs new file mode 100644 index 0000000..d378d7d --- /dev/null +++ b/Helpers/ADS.Helpers.Data/TenantDbConnectionFactory.cs @@ -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); + } +} diff --git a/Helpers/ADS.Helpers.Data/readme.md b/Helpers/ADS.Helpers.Data/readme.md new file mode 100644 index 0000000..27cb6b7 --- /dev/null +++ b/Helpers/ADS.Helpers.Data/readme.md @@ -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(); \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Localization/ADS.Helpers.Localization.csproj b/Helpers/ADS.Helpers.Localization/ADS.Helpers.Localization.csproj new file mode 100644 index 0000000..aa52f4d --- /dev/null +++ b/Helpers/ADS.Helpers.Localization/ADS.Helpers.Localization.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/Helpers/ADS.Helpers.Localization/Abstractions/IMessageCatalog.cs b/Helpers/ADS.Helpers.Localization/Abstractions/IMessageCatalog.cs new file mode 100644 index 0000000..7013f89 --- /dev/null +++ b/Helpers/ADS.Helpers.Localization/Abstractions/IMessageCatalog.cs @@ -0,0 +1,6 @@ +namespace ADS.Helpers.Localization.Abstractions; + +public interface IMessageCatalog +{ + string Get(string source,string code, string culture); +} diff --git a/Helpers/ADS.Helpers.Localization/Class1.cs b/Helpers/ADS.Helpers.Localization/Class1.cs new file mode 100644 index 0000000..87c2a23 --- /dev/null +++ b/Helpers/ADS.Helpers.Localization/Class1.cs @@ -0,0 +1,6 @@ +namespace ADS.Helpers.Localization; + +public class Class1 +{ + +} diff --git a/Helpers/ADS.Helpers.Localization/Extensions/ServiceCollectionExtensions.cs b/Helpers/ADS.Helpers.Localization/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..9be11b4 --- /dev/null +++ b/Helpers/ADS.Helpers.Localization/Extensions/ServiceCollectionExtensions.cs @@ -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(); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } +} diff --git a/Helpers/ADS.Helpers.Localization/InMemoryMessageCatalog.cs b/Helpers/ADS.Helpers.Localization/InMemoryMessageCatalog.cs new file mode 100644 index 0000000..0a1d7d5 --- /dev/null +++ b/Helpers/ADS.Helpers.Localization/InMemoryMessageCatalog.cs @@ -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 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; + } +} diff --git a/Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj b/Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj new file mode 100644 index 0000000..b8ee1bf --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/Helpers/ADS.Helpers.Middleware/ADSException/AdsExceptionMiddleware.cs b/Helpers/ADS.Helpers.Middleware/ADSException/AdsExceptionMiddleware.cs new file mode 100644 index 0000000..3443331 --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/ADSException/AdsExceptionMiddleware.cs @@ -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 + { + 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 + })); + } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/ADSException/FileAdsErrorLogger.cs b/Helpers/ADS.Helpers.Middleware/ADSException/FileAdsErrorLogger.cs new file mode 100644 index 0000000..c1ae20e --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/ADSException/FileAdsErrorLogger.cs @@ -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 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); + } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/ADSException/IAdsErrorLogger.cs b/Helpers/ADS.Helpers.Middleware/ADSException/IAdsErrorLogger.cs new file mode 100644 index 0000000..5805344 --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/ADSException/IAdsErrorLogger.cs @@ -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); +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/ADSException/LoggingOptions.cs b/Helpers/ADS.Helpers.Middleware/ADSException/LoggingOptions.cs new file mode 100644 index 0000000..89320ce --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/ADSException/LoggingOptions.cs @@ -0,0 +1,6 @@ +namespace ADS.Helpers.Middleware.ADSException; + +public sealed class LoggingOptions +{ + public string LogFolder { get; set; } = "logs"; +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/Extensions/AdsApiResponseExtensions.cs b/Helpers/ADS.Helpers.Middleware/Extensions/AdsApiResponseExtensions.cs new file mode 100644 index 0000000..517d974 --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/Extensions/AdsApiResponseExtensions.cs @@ -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( + configuration.GetSection("LoggingOptions")); + + services.AddSingleton(); + + services.AddControllers(o => + { + o.Filters.Add(); + }); + + return services; + } + + public static IApplicationBuilder UseAdsApiResponse(this IApplicationBuilder app) + { + app.UseMiddleware(); + return app; + } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/Extensions/AdsExtensions.cs b/Helpers/ADS.Helpers.Middleware/Extensions/AdsExtensions.cs new file mode 100644 index 0000000..292c075 --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/Extensions/AdsExtensions.cs @@ -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(); + + 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(configuration.GetSection("MultiTenant")); + services.AddSingleton(); + // services.AddSingleton();// reemplazado por ConfigurationTenantStore + services.AddSingleton(); + services.AddScoped(); + + 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(); + } + + 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(); + return services; + } + + public static IApplicationBuilder UseAdsInternalUser(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } + +} diff --git a/Helpers/ADS.Helpers.Middleware/Http/ApiResponse.cs b/Helpers/ADS.Helpers.Middleware/Http/ApiResponse.cs new file mode 100644 index 0000000..ba2b046 --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/Http/ApiResponse.cs @@ -0,0 +1,20 @@ +namespace ADS.Helpers.Middleware.Http; + +public sealed class ApiResponse +{ + 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; } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/Http/ApiResponseWrapFilter.cs b/Helpers/ADS.Helpers.Middleware/Http/ApiResponseWrapFilter.cs new file mode 100644 index 0000000..81fe281 --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/Http/ApiResponseWrapFilter.cs @@ -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 + { + 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" + }; +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/Http/PagedQuery.cs b/Helpers/ADS.Helpers.Middleware/Http/PagedQuery.cs new file mode 100644 index 0000000..b8c0507 --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/Http/PagedQuery.cs @@ -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; +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/Http/RequestOf.cs b/Helpers/ADS.Helpers.Middleware/Http/RequestOf.cs new file mode 100644 index 0000000..2e62f47 --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/Http/RequestOf.cs @@ -0,0 +1,11 @@ +namespace ADS.Helpers.Middleware.Http; + +public sealed class RequestOf +{ + 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; +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/InternalUser/IInternalUserContextAccessor.cs b/Helpers/ADS.Helpers.Middleware/InternalUser/IInternalUserContextAccessor.cs new file mode 100644 index 0000000..3578a9b --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/InternalUser/IInternalUserContextAccessor.cs @@ -0,0 +1,6 @@ +namespace ADS.Helpers.Middleware.InternalUser; + +public interface IInternalUserContextAccessor +{ + InternalUserContext? Current { get; set; } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/InternalUser/InternalAuthorizeAttribute.cs b/Helpers/ADS.Helpers.Middleware/InternalUser/InternalAuthorizeAttribute.cs new file mode 100644 index 0000000..864a69b --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/InternalUser/InternalAuthorizeAttribute.cs @@ -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() + .Any(); + + if (allowAnonymous) + return; + + var accessor = context.HttpContext.RequestServices + .GetService(typeof(IInternalUserContextAccessor)) as IInternalUserContextAccessor; + + if (accessor?.Current == null) + { + context.Result = new UnauthorizedResult(); + } + } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContext.cs b/Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContext.cs new file mode 100644 index 0000000..1067ecb --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContext.cs @@ -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; } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContextAccessor.cs b/Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContextAccessor.cs new file mode 100644 index 0000000..425969e --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContextAccessor.cs @@ -0,0 +1,6 @@ +namespace ADS.Helpers.Middleware.InternalUser; + +public class InternalUserContextAccessor : IInternalUserContextAccessor +{ + public InternalUserContext? Current { get; set; } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContextMiddleware.cs b/Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContextMiddleware.cs new file mode 100644 index 0000000..6fe70fb --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/InternalUser/InternalUserContextMiddleware.cs @@ -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); + } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.Middleware/Logging/DefaultAppLogger.cs b/Helpers/ADS.Helpers.Middleware/Logging/DefaultAppLogger.cs new file mode 100644 index 0000000..384c352 --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/Logging/DefaultAppLogger.cs @@ -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 _logger; + + public DefaultAppLogger(ILogger 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); +} diff --git a/Helpers/ADS.Helpers.Middleware/Tenant/TenantMiddleware.cs b/Helpers/ADS.Helpers.Middleware/Tenant/TenantMiddleware.cs new file mode 100644 index 0000000..a567e94 --- /dev/null +++ b/Helpers/ADS.Helpers.Middleware/Tenant/TenantMiddleware.cs @@ -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); + } + } +} diff --git a/Helpers/ADS.Helpers.MultiTenant/ADS.Helpers.MultiTenant.csproj b/Helpers/ADS.Helpers.MultiTenant/ADS.Helpers.MultiTenant.csproj new file mode 100644 index 0000000..6507c7c --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/ADS.Helpers.MultiTenant.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/Helpers/ADS.Helpers.MultiTenant/Abstractions/ITenantResolver.cs b/Helpers/ADS.Helpers.MultiTenant/Abstractions/ITenantResolver.cs new file mode 100644 index 0000000..093f6a2 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/Abstractions/ITenantResolver.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Http; + +namespace ADS.Helpers.MultiTenant.Abstractions; + +public interface ITenantResolver +{ + string? Resolve(HttpContext context); +} diff --git a/Helpers/ADS.Helpers.MultiTenant/Abstractions/ITenantStore.cs b/Helpers/ADS.Helpers.MultiTenant/Abstractions/ITenantStore.cs new file mode 100644 index 0000000..0f1eb29 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/Abstractions/ITenantStore.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using ADS.Helpers.MultiTenant.Context; + +namespace ADS.Helpers.MultiTenant.Abstractions; + +public interface ITenantStore +{ + Task GetAsync(string tenantKey); +} diff --git a/Helpers/ADS.Helpers.MultiTenant/Configuration/TenantOptions.cs b/Helpers/ADS.Helpers.MultiTenant/Configuration/TenantOptions.cs new file mode 100644 index 0000000..d960624 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/Configuration/TenantOptions.cs @@ -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"]; +} diff --git a/Helpers/ADS.Helpers.MultiTenant/Context/ITenantContextAccessor.cs b/Helpers/ADS.Helpers.MultiTenant/Context/ITenantContextAccessor.cs new file mode 100644 index 0000000..edb47af --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/Context/ITenantContextAccessor.cs @@ -0,0 +1,7 @@ +namespace ADS.Helpers.MultiTenant.Context; + +public interface ITenantContextAccessor +{ + TenantContext? Current { get; } + void SetCurrent(TenantContext? context); +} diff --git a/Helpers/ADS.Helpers.MultiTenant/Context/InMemoryTenantStore .cs b/Helpers/ADS.Helpers.MultiTenant/Context/InMemoryTenantStore .cs new file mode 100644 index 0000000..7b5dd9e --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/Context/InMemoryTenantStore .cs @@ -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 _tenants = new(StringComparer.OrdinalIgnoreCase) + { + ["demo"] = new TenantContext { TenantId = "demo", EmpresaId = 172, ClienteId = 1 } + }; +public Task GetAsync(string tenantKey) + { + _tenants.TryGetValue(tenantKey, out var tenant); + return Task.FromResult(tenant); + } +} diff --git a/Helpers/ADS.Helpers.MultiTenant/Context/TenantContext.cs b/Helpers/ADS.Helpers.MultiTenant/Context/TenantContext.cs new file mode 100644 index 0000000..4ad07a3 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/Context/TenantContext.cs @@ -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 CustomSettings { get; set; } = new(); +} + +public sealed class DatabaseConfig +{ + public string Provider { get; set; } = "SqlServer"; // SqlServer | Postgres + public string ConnectionString { get; set; } = string.Empty; +} diff --git a/Helpers/ADS.Helpers.MultiTenant/Context/TenantContextAccessor.cs b/Helpers/ADS.Helpers.MultiTenant/Context/TenantContextAccessor.cs new file mode 100644 index 0000000..e0f7042 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/Context/TenantContextAccessor.cs @@ -0,0 +1,12 @@ +using System.Threading; + +namespace ADS.Helpers.MultiTenant.Context; + +public sealed class TenantContextAccessor : ITenantContextAccessor +{ + private static readonly AsyncLocal _current = new(); + + public TenantContext? Current => _current.Value; + + public void SetCurrent(TenantContext? context) => _current.Value = context; +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.MultiTenant/Data/IDbConnectionFactory.cs b/Helpers/ADS.Helpers.MultiTenant/Data/IDbConnectionFactory.cs new file mode 100644 index 0000000..fc5f0e0 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/Data/IDbConnectionFactory.cs @@ -0,0 +1,8 @@ +using System.Data; + +namespace ADS.Helpers.MultiTenant.Data; + +public interface IDbConnectionFactory +{ + IDbConnection Create(); +} diff --git a/Helpers/ADS.Helpers.MultiTenant/Data/TenantDbConnectionFactory.cs b/Helpers/ADS.Helpers.MultiTenant/Data/TenantDbConnectionFactory.cs new file mode 100644 index 0000000..216ce5d --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/Data/TenantDbConnectionFactory.cs @@ -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); + } +} diff --git a/Helpers/ADS.Helpers.MultiTenant/InternalRouting/ApplicationBuilderExtensions.cs b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..e898280 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/ApplicationBuilderExtensions.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Builder; + +namespace ADS.Helpers.MultiTenant.InternalRouting; + +public static class ApplicationBuilderExtensions +{ + // MICRO: valida headers internos antes de controllers + public static IApplicationBuilder UseInternalTenantDb(this IApplicationBuilder app) + => app.UseMiddleware(); +} diff --git a/Helpers/ADS.Helpers.MultiTenant/InternalRouting/ITenantConnectionStringProvider.cs b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/ITenantConnectionStringProvider.cs new file mode 100644 index 0000000..e69de29 diff --git a/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalHeaders.cs b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalHeaders.cs new file mode 100644 index 0000000..5f08895 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalHeaders.cs @@ -0,0 +1,24 @@ +namespace ADS.Helpers.MultiTenant.InternalRouting; + +public static class InternalHeaders +{ + public const string Tenant = "X-Internal-Tenant"; + public const string Conn = "X-Internal-Conn"; + public const string Timestamp = "X-Internal-Ts"; + public const string Signature = "X-Internal-Sig"; + // NUEVOS (lo que faltaba para no “inventar” en GPS) + public const string EmpresaId = "X-Internal-EmpresaId"; + public const string ClienteId = "X-Internal-ClienteId"; + + // Opcionales + public const string Culture = "X-Internal-Culture"; + public const string Theme = "X-Internal-Theme"; + public const string DbProvider= "X-Internal-DbProvider"; + + public const string CveUser = "X-Internal-CveUser"; + public const string CveEmpresa = "X-Internal-CveEmpresa"; + public const string CveCliente = "X-Internal-CveCliente"; + public const string User_Timestamp = "X-Internal-User-Timestamp"; + public const string User_Signature = "X-Internal-User-Signature"; +} + \ No newline at end of file diff --git a/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalRoutingOptions.cs b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalRoutingOptions.cs new file mode 100644 index 0000000..d6db065 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalRoutingOptions.cs @@ -0,0 +1,21 @@ +namespace ADS.Helpers.MultiTenant.InternalRouting; + +public sealed class InternalRoutingOptions +{ + public string SharedSecret { get; set; } = default!; + public int MaxSkewSeconds { get; set; } = 120; + + public string HeaderTenant { get; set; } = InternalHeaders.Tenant; + public string HeaderConn { get; set; } = InternalHeaders.Conn; + public string HeaderTs { get; set; } = InternalHeaders.Timestamp; + public string HeaderSig { get; set; } = InternalHeaders.Signature; + + // NUEVOS + public string HeaderEmpresaId { get; set; } = InternalHeaders.EmpresaId; + public string HeaderClienteId { get; set; } = InternalHeaders.ClienteId; + + // Opcionales + public string HeaderCulture { get; set; } = InternalHeaders.Culture; + public string HeaderTheme { get; set; } = InternalHeaders.Theme; + public string HeaderDbProvider{ get; set; } = InternalHeaders.DbProvider; +} diff --git a/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalSignature.cs b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalSignature.cs new file mode 100644 index 0000000..5d9fa97 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalSignature.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ADS.Helpers.MultiTenant.InternalRouting; + +public static class InternalSignature +{ + public static string Compute( + string secret, + string tenantId, + string conn, + int empresaId, + int clienteId, + string provider, + string culture, + string theme, + long tsUnix) + { + var payload = $"{tenantId}\n{conn}\n{empresaId}\n{clienteId}\n{provider}\n{culture}\n{theme}\n{tsUnix}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); + return Convert.ToHexString(hash); // HEX uppercase + } + + // Backward compatible (por si algún host viejo sigue firmando con el formato anterior) + public static string Compute(string secret, string tenantId, string conn, long tsUnix) + => Compute(secret, tenantId, conn, 0, 0, "SqlServer", "es-MX", "", tsUnix); + + public static bool FixedTimeEqualsHex(string expectedHex, string providedHex) + { + if (expectedHex.Length != providedHex.Length) return false; + return CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(expectedHex), + Encoding.ASCII.GetBytes(providedHex)); + } + + public static bool IsTimestampValid(long tsUnix, int maxSkewSeconds) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var diff = Math.Abs(now - tsUnix); + return diff <= maxSkewSeconds; + } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbHeaderWriter.cs b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbHeaderWriter.cs new file mode 100644 index 0000000..db7b409 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbHeaderWriter.cs @@ -0,0 +1,46 @@ +using ADS.Helpers.MultiTenant.Context; +using Microsoft.Extensions.Options; + +namespace ADS.Helpers.MultiTenant.InternalRouting; + +public interface IInternalTenantDbHeaderWriter +{ + long AddInternalHeaders(IDictionary headers, TenantContext tenant); +} + +public sealed class InternalTenantDbHeaderWriter : IInternalTenantDbHeaderWriter +{ + private readonly InternalRoutingOptions _opt; + + public InternalTenantDbHeaderWriter(IOptions opt) => _opt = opt.Value; + + public long AddInternalHeaders(IDictionary headers, TenantContext tenant) + { + var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + var tenantId = tenant.TenantId ?? ""; + var conn = tenant.Database?.ConnectionString ?? ""; + var empresaId = tenant.EmpresaId; + var clienteId = tenant.ClienteId; + var provider = tenant.Database?.Provider ?? "SqlServer"; + var culture = tenant.Culture ?? "es-MX"; + var theme = tenant.Theme ?? ""; + + var sig = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, empresaId, clienteId, provider, culture, theme, ts); + + headers[_opt.HeaderTenant] = tenantId; + headers[_opt.HeaderConn] = conn; + + headers[_opt.HeaderEmpresaId] = empresaId.ToString(); + headers[_opt.HeaderClienteId] = clienteId.ToString(); + + headers[_opt.HeaderDbProvider] = provider; + headers[_opt.HeaderCulture] = culture; + headers[_opt.HeaderTheme] = theme; + + headers[_opt.HeaderTs] = ts.ToString(); + headers[_opt.HeaderSig] = sig; + + return ts; + } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbHeaders.cs b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbHeaders.cs new file mode 100644 index 0000000..e3261fe --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbHeaders.cs @@ -0,0 +1,7 @@ +namespace ADS.Helpers.MultiTenant.InternalRouting; + +public static class InternalTenantDbHeaders +{ + public const string ItemTenantId = "Internal.TenantId"; + public const string ItemConn = "Internal.Conn"; +} diff --git a/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbMiddleware.cs b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbMiddleware.cs new file mode 100644 index 0000000..91afcf2 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/InternalTenantDbMiddleware.cs @@ -0,0 +1,134 @@ +using ADS.Helpers.MultiTenant.Context; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace ADS.Helpers.MultiTenant.InternalRouting; + +public sealed class InternalTenantDbMiddleware +{ + private readonly RequestDelegate _next; + private readonly InternalRoutingOptions _opt; + private readonly ITenantContextAccessor _tenantAccessor; + + public InternalTenantDbMiddleware( + RequestDelegate next, + IOptions opt, + ITenantContextAccessor tenantAccessor) + { + _next = next; + _opt = opt.Value; + _tenantAccessor = tenantAccessor; + } + + public async Task InvokeAsync(HttpContext ctx) + { + if (!TryRead(ctx, + out var tenantId, + out var conn, + out var empresaId, + out var clienteId, + out var provider, + out var culture, + out var theme, + out var ts, + out var sig)) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + if (!InternalSignature.IsTimestampValid(ts, _opt.MaxSkewSeconds)) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + // Firma NUEVA (con empresa/cliente/etc) + var expected = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, empresaId, clienteId, provider, culture, theme, ts); + + // Backward compatible: si el Host todavía firma “viejo”, cae al compute viejo + if (!InternalSignature.FixedTimeEqualsHex(expected, sig)) + { + var expectedOld = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, ts); + if (!InternalSignature.FixedTimeEqualsHex(expectedOld, sig)) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + } + + // backward compatible items + ctx.Items[InternalTenantDbHeaders.ItemTenantId] = tenantId; + ctx.Items[InternalTenantDbHeaders.ItemConn] = conn; + + _tenantAccessor.SetCurrent(new TenantContext + { + TenantId = tenantId, + EmpresaId = empresaId, + ClienteId = clienteId, + Culture = culture, + Theme = theme, + Database = new DatabaseConfig + { + Provider = provider, + ConnectionString = conn + } + }); + + try + { + await _next(ctx); + } + finally + { + _tenantAccessor.SetCurrent(null); + } + } + + private bool TryRead( + HttpContext ctx, + out string tenantId, + out string conn, + out int empresaId, + out int clienteId, + out string provider, + out string culture, + out string theme, + out long ts, + out string sig) + { + tenantId = conn = sig = ""; + empresaId = 0; + clienteId = 0; + provider = "SqlServer"; + culture = "es-MX"; + theme = ""; + ts = 0; + + if (!ctx.Request.Headers.TryGetValue(_opt.HeaderTenant, out var t)) return false; + if (!ctx.Request.Headers.TryGetValue(_opt.HeaderConn, out var c)) return false; + if (!ctx.Request.Headers.TryGetValue(_opt.HeaderTs, out var tsStr)) return false; + if (!ctx.Request.Headers.TryGetValue(_opt.HeaderSig, out var s)) return false; + + tenantId = t.ToString(); + conn = c.ToString(); + sig = s.ToString(); + + // NUEVOS obligatorios para Sicrep (si no vienen, dejamos 0 y el SP puede trabajar con -1) + if (ctx.Request.Headers.TryGetValue(_opt.HeaderEmpresaId, out var eStr)) + int.TryParse(eStr.ToString(), out empresaId); + + if (ctx.Request.Headers.TryGetValue(_opt.HeaderClienteId, out var clStr)) + int.TryParse(clStr.ToString(), out clienteId); + + // Opcionales + if (ctx.Request.Headers.TryGetValue(_opt.HeaderDbProvider, out var p)) provider = p.ToString(); + if (ctx.Request.Headers.TryGetValue(_opt.HeaderCulture, out var cul)) culture = cul.ToString(); + if (ctx.Request.Headers.TryGetValue(_opt.HeaderTheme, out var th)) theme = th.ToString(); + + return !string.IsNullOrWhiteSpace(tenantId) + && !string.IsNullOrWhiteSpace(conn) + && long.TryParse(tsStr.ToString(), out ts) + && !string.IsNullOrWhiteSpace(sig); + } +} \ No newline at end of file diff --git a/Helpers/ADS.Helpers.MultiTenant/InternalRouting/ServiceCollectionExtensions.cs b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..2041215 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/ServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using ADS.Helpers.MultiTenant.Context; +using ADS.Helpers.MultiTenant.Data; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ADS.Helpers.MultiTenant.InternalRouting; + +public static class ServiceCollectionExtensions +{ + // MICRO: registra middleware options + HttpContextAccessor + DB factory + public static IServiceCollection AddInternalTenantDb(this IServiceCollection services, IConfiguration cfg) + { + services.AddHttpContextAccessor(); + services.Configure(cfg.GetSection("InternalRouting")); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + return services; + } + + // HOST: registra solo el writer (para agregar headers internos) + public static IServiceCollection AddInternalTenantDbHeaderWriter(this IServiceCollection services, IConfiguration cfg) + { + services.Configure(cfg.GetSection("InternalRouting")); + services.AddSingleton(); + return services; + } +} diff --git a/Helpers/ADS.Helpers.MultiTenant/InternalRouting/TenantConnectionStringProvider.cs b/Helpers/ADS.Helpers.MultiTenant/InternalRouting/TenantConnectionStringProvider.cs new file mode 100644 index 0000000..e69de29 diff --git a/Helpers/ADS.Helpers.MultiTenant/Resolvers/HeaderTenantResolver.cs b/Helpers/ADS.Helpers.MultiTenant/Resolvers/HeaderTenantResolver.cs new file mode 100644 index 0000000..db5cdc5 --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/Resolvers/HeaderTenantResolver.cs @@ -0,0 +1,25 @@ +using ADS.Helpers.MultiTenant.Abstractions; +using ADS.Helpers.MultiTenant.Configuration; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace ADS.Helpers.MultiTenant.Resolvers; + +public sealed class HeaderTenantResolver : ITenantResolver +{ + private readonly TenantOptions _opt; + + public HeaderTenantResolver(IOptions opt) => _opt = opt.Value; + + public string? Resolve(HttpContext context) + { + + if (context.Request.Headers.TryGetValue(_opt.HeaderName, out var value)) + return value.ToString(); + + if (!string.IsNullOrWhiteSpace(_opt.DefaultTenantId)) + return _opt.DefaultTenantId; + + return null; + } +} diff --git a/Helpers/ADS.Helpers.MultiTenant/Stores/ConfigurationTenantStore.cs b/Helpers/ADS.Helpers.MultiTenant/Stores/ConfigurationTenantStore.cs new file mode 100644 index 0000000..893de5c --- /dev/null +++ b/Helpers/ADS.Helpers.MultiTenant/Stores/ConfigurationTenantStore.cs @@ -0,0 +1,22 @@ +using ADS.Helpers.MultiTenant.Abstractions; +using ADS.Helpers.MultiTenant.Context; +using Microsoft.Extensions.Configuration; + +namespace ADS.Helpers.MultiTenant.Stores; + +public sealed class ConfigurationTenantStore : ITenantStore +{ + private readonly IConfiguration _cfg; + + public ConfigurationTenantStore(IConfiguration cfg) => _cfg = cfg; + + public Task GetAsync(string tenantKey) + { + var section = _cfg.GetSection($"Tenants:{tenantKey}"); + if (!section.Exists()) return Task.FromResult(null); + + var tenant = section.Get() ?? new TenantContext(); + tenant.TenantId = tenantKey; // importante + return Task.FromResult(tenant); + } +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/ADS.Helpers.NotifyCenter.csproj b/Helpers/ADS.Helpers.NotifyCenter/ADS.Helpers.NotifyCenter.csproj new file mode 100644 index 0000000..bce6bd7 --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/ADS.Helpers.NotifyCenter.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotificationProvider.cs b/Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotificationProvider.cs new file mode 100644 index 0000000..4c2312a --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotificationProvider.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; +using ADS.Helpers.NotifyCenter.Models; + +namespace ADS.Helpers.NotifyCenter.Abstractions; + +public interface INotificationProvider +{ + NotificationChannel Channel { get; } + string Name { get; } + + Task SendAsync(NotificationMessage message, CancellationToken ct = default); +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotificationSettingsProvider.cs b/Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotificationSettingsProvider.cs new file mode 100644 index 0000000..7fb4faa --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotificationSettingsProvider.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace ADS.Helpers.NotifyCenter.Abstractions; + +public interface INotificationSettingsProvider +{ + Task GetAsync(string tenantId, CancellationToken ct = default) + where TSettings : class, new(); +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotifyCenter.cs b/Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotifyCenter.cs new file mode 100644 index 0000000..812aad3 --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Abstractions/INotifyCenter.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; +using ADS.Helpers.NotifyCenter.Models; + +namespace ADS.Helpers.NotifyCenter.Abstractions; + +public interface INotifyCenter +{ + Task SendAsync(NotificationMessage message, CancellationToken ct = default); +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Extensions/ServiceCollectionExtensions.cs b/Helpers/ADS.Helpers.NotifyCenter/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..dabe1c5 --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using ADS.Helpers.NotifyCenter.Abstractions; +using ADS.Helpers.NotifyCenter.Models; +using ADS.Helpers.NotifyCenter.Providers; +using ADS.Helpers.NotifyCenter.Providers.Smtp; +using Microsoft.Extensions.DependencyInjection; + +namespace ADS.Helpers.NotifyCenter.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddAdsNotifyCenter(this IServiceCollection services) + { + services.AddOptions(); + services.AddSingleton(); + + // Default settings provider (replace later with tenant-aware provider) + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + // Providers + services.AddSingleton(); + + return services; + } + + // Helper to configure SMTP quickly when using InMemoryNotificationSettingsProvider + public static IServiceCollection ConfigureAdsSmtp(this IServiceCollection services, SmtpOptions options, string? tenantId = null) + { + var sp = services.BuildServiceProvider(); + var mem = sp.GetRequiredService(); + mem.Set(options, tenantId); + return services; + } +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Models/Localization/MessageSources.cs b/Helpers/ADS.Helpers.NotifyCenter/Models/Localization/MessageSources.cs new file mode 100644 index 0000000..8002938 --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Models/Localization/MessageSources.cs @@ -0,0 +1,7 @@ +namespace ADS.Helpers.NotifyCenter.Models; + +public static class MessageSources +{ + public const string NotifyCenter = "ADS.NotifyCenter"; + public const string SmtpProvider = "ADS.NotifyCenter.Smtp"; +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Models/Localization/NotifyCodes.cs b/Helpers/ADS.Helpers.NotifyCenter/Models/Localization/NotifyCodes.cs new file mode 100644 index 0000000..1538c9e --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Models/Localization/NotifyCodes.cs @@ -0,0 +1,13 @@ +namespace ADS.Helpers.NotifyCenter.Models; + +public static class NotifyCodes +{ + public const string Ok = "NOTIFY_OK"; + public const string NoProvider = "NOTIFY_NO_PROVIDER"; + public const string InvalidMessage = "NOTIFY_INVALID_MESSAGE"; + + public const string SmtpNotConfigured = "SMTP_NOT_CONFIGURED"; + public const string SmtpSendFailed = "SMTP_SEND_FAILED"; + public const string TenantContextMissing = "TENANT_CONTEXT_MISSING"; + +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Models/NotificationChannel.cs b/Helpers/ADS.Helpers.NotifyCenter/Models/NotificationChannel.cs new file mode 100644 index 0000000..446c6be --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Models/NotificationChannel.cs @@ -0,0 +1,11 @@ +namespace ADS.Helpers.NotifyCenter.Models; + +public enum NotificationChannel +{ + SmtpEmail = 1, + SignalR = 2, + Sms = 3, + WhatsApp = 4, + Webhook = 5, + Push = 6 +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Models/NotificationMessage.cs b/Helpers/ADS.Helpers.NotifyCenter/Models/NotificationMessage.cs new file mode 100644 index 0000000..d4e7df9 --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Models/NotificationMessage.cs @@ -0,0 +1,26 @@ + + +namespace ADS.Helpers.NotifyCenter.Models; + +public sealed class NotificationMessage +{ + public required NotificationChannel Channel { get; init; } + + // Generic recipients. Providers can interpret these as they need. + // For SMTP: To/Cc/Bcc are emails. + public List To { get; init; } = new(); + public List Cc { get; init; } = new(); + public List Bcc { get; init; } = new(); + + public string? Subject { get; init; } + public string? BodyText { get; init; } + public string? BodyHtml { get; init; } + + // Optional provider-specific metadata (e.g. phone numbers formatting, template ids, etc.) + public Dictionary Metadata { get; init; } = new(); + + // Optional correlation id from caller + public string? TraceId { get; init; } + public string? Culture { get; init; } + +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Models/NotificationResult.cs b/Helpers/ADS.Helpers.NotifyCenter/Models/NotificationResult.cs new file mode 100644 index 0000000..79ce3c5 --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Models/NotificationResult.cs @@ -0,0 +1,57 @@ +namespace ADS.Helpers.NotifyCenter.Models; + +public sealed class NotificationResult +{ + public bool Success { get; init; } + + public string Source { get; init; } = ""; // quien emitió + public string Code { get; init; } = ""; // keyword estable + + public string? Message { get; init; } // ya traducido (si se resolvió) + public string? DebugMessage { get; init; } + + public string? ProviderName { get; init; } + public string? ProviderMessageId { get; init; } + + public static NotificationResult Ok( + string providerName, + string source, + string code, + string? providerMessageId = null, + string? debugMessage = null) + => new() + { + Success = true, + ProviderName = providerName, + Source = source, + Code = code, + ProviderMessageId = providerMessageId, + DebugMessage = debugMessage + }; + + public static NotificationResult Fail( + string providerName, + string source, + string code, + string? debugMessage = null) + => new() + { + Success = false, + ProviderName = providerName, + Source = source, + Code = code, + DebugMessage = debugMessage + }; + + public NotificationResult WithMessage(string message) + => new() + { + Success = Success, + ProviderName = ProviderName, + ProviderMessageId = ProviderMessageId, + Source = Source, + Code = Code, + DebugMessage = DebugMessage, + Message = message + }; +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Models/NotifyCenterOptions.cs b/Helpers/ADS.Helpers.NotifyCenter/Models/NotifyCenterOptions.cs new file mode 100644 index 0000000..27d1b2c --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Models/NotifyCenterOptions.cs @@ -0,0 +1,10 @@ +namespace ADS.Helpers.NotifyCenter.Models; + +public sealed class NotifyCenterOptions +{ + // If true, NotifyCenter will throw when no provider is registered for the requested channel. + public bool ThrowIfNoProvider { get; set; } = true; + + // If true, NotifyCenter will validate basic fields and fail fast. + public bool ValidateMessage { get; set; } = true; +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/NotifyCenter.cs b/Helpers/ADS.Helpers.NotifyCenter/NotifyCenter.cs new file mode 100644 index 0000000..fcc9eb9 --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/NotifyCenter.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ADS.Helpers.Base.Abstractions; +using ADS.Helpers.Localization.Abstractions; +using ADS.Helpers.MultiTenant.Context; +using ADS.Helpers.NotifyCenter.Abstractions; +using ADS.Helpers.NotifyCenter.Models; +using Microsoft.Extensions.Options; + +namespace ADS.Helpers.NotifyCenter; + +public sealed class NotifyCenter : INotifyCenter +{ + private readonly IReadOnlyList _providers; + private readonly NotifyCenterOptions _options; + private readonly IMessageCatalog _catalog; + private readonly IAppLogger _log; + private readonly ITenantContextAccessor _tenantAccessor; + + private const string DefaultCulture = "es-MX"; + + public NotifyCenter( + IEnumerable providers, + IOptions options, + IMessageCatalog catalog, + IAppLogger log, + ITenantContextAccessor tenantAccessor) + { + _providers = providers.ToList(); + _options = options.Value; + _catalog = catalog; + _log = log; + _tenantAccessor = tenantAccessor; + } + + public async Task SendAsync(NotificationMessage message, CancellationToken ct = default) + { + var tenant = _tenantAccessor.Current; + + if (tenant is null || string.IsNullOrWhiteSpace(tenant.TenantId)) + { + var dbg = "TenantContext is not available. Ensure TenantMiddleware runs before using NotifyCenter."; + + _log.Error( + source: MessageSources.NotifyCenter, + code: NotifyCodes.TenantContextMissing, + message: dbg, + ex: null, + data: new { message.TraceId }); + + var fail = NotificationResult.Fail( + providerName: "NotifyCenter", + source: MessageSources.NotifyCenter, + code: NotifyCodes.TenantContextMissing, + debugMessage: dbg); + + return Localize(fail, DefaultCulture); + } + + var culture = string.IsNullOrWhiteSpace(tenant.Culture) ? DefaultCulture : tenant.Culture; + + if (_options.ValidateMessage) + { + var validation = Validate(message); + if (!validation.Success) + return Localize(validation, culture); + } + + var provider = _providers.FirstOrDefault(p => p.Channel == message.Channel); + + if (provider is null) + { + var debug = $"No notification provider registered for channel '{message.Channel}'."; + + if (_options.ThrowIfNoProvider) + { + _log.Error( + source: MessageSources.NotifyCenter, + code: NotifyCodes.NoProvider, + message: debug, + ex: null, + data: new { message.TraceId, tenant.TenantId }); + + throw new InvalidOperationException(debug); + } + + _log.Warn( + source: MessageSources.NotifyCenter, + code: NotifyCodes.NoProvider, + message: debug, + data: new { message.TraceId, tenant.TenantId }); + + var fail = NotificationResult.Fail( + providerName: "NotifyCenter", + source: MessageSources.NotifyCenter, + code: NotifyCodes.NoProvider, + debugMessage: debug); + + return Localize(fail, culture); + } + + // Important: providers can still use tenant via TenantContextAccessor if needed, + // or you can pass tenant id through message.Metadata. + // Here we pass it so providers that are settings-driven can resolve settings by tenant. + message.Metadata["tenantId"] = tenant.TenantId; + + var result = await provider.SendAsync(message, ct); + + if (!result.Success) + { + _log.Error( + source: result.Source, + code: result.Code, + message: "Notification provider failed.", + ex: null, + data: new + { + message.TraceId, + tenant.TenantId, + result.ProviderName, + result.DebugMessage + }); + } + + return Localize(result, culture); + } + + private NotificationResult Localize(NotificationResult result, string culture) + { + var localizedMessage = _catalog.Get( + source: result.Source, + code: result.Code, + culture: culture); + + return result.WithMessage(localizedMessage); + } + + private static NotificationResult Validate(NotificationMessage message) + { + if (message.To.Count == 0 && message.Cc.Count == 0 && message.Bcc.Count == 0) + { + return NotificationResult.Fail( + providerName: "NotifyCenter", + source: MessageSources.NotifyCenter, + code: NotifyCodes.InvalidMessage, + debugMessage: "To/Cc/Bcc are all empty."); + } + + if (string.IsNullOrWhiteSpace(message.Subject) && + message.Channel == NotificationChannel.SmtpEmail) + { + return NotificationResult.Fail( + providerName: "NotifyCenter", + source: MessageSources.NotifyCenter, + code: NotifyCodes.InvalidMessage, + debugMessage: "Subject is required for SMTP."); + } + + if (string.IsNullOrWhiteSpace(message.BodyText) && + string.IsNullOrWhiteSpace(message.BodyHtml)) + { + return NotificationResult.Fail( + providerName: "NotifyCenter", + source: MessageSources.NotifyCenter, + code: NotifyCodes.InvalidMessage, + debugMessage: "BodyText and BodyHtml are empty."); + } + + return NotificationResult.Ok( + providerName: "NotifyCenter", + source: MessageSources.NotifyCenter, + code: NotifyCodes.Ok, + providerMessageId: null, + debugMessage: "Validation passed."); + } +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Providers/InMemoryNotificationSettingsProvider.cs b/Helpers/ADS.Helpers.NotifyCenter/Providers/InMemoryNotificationSettingsProvider.cs new file mode 100644 index 0000000..c55da49 --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Providers/InMemoryNotificationSettingsProvider.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using ADS.Helpers.NotifyCenter.Abstractions; + +namespace ADS.Helpers.NotifyCenter.Providers; + +// Simple in-memory settings store. Later you replace this with a tenant-aware provider (DB/SQLite/etc.) +public sealed class InMemoryNotificationSettingsProvider : INotificationSettingsProvider +{ + private readonly ConcurrentDictionary<(Type type, string? tenantId), object> _store = new(); + + public Task GetAsync(string? tenantId, CancellationToken ct = default) + where TSettings : class, new() + { + var key = (typeof(TSettings), tenantId); + if (_store.TryGetValue(key, out var obj) && obj is TSettings typed) + return Task.FromResult(typed); + + // fallback to default (tenantId = null) + var keyDefault = (typeof(TSettings), (string?)null); + if (_store.TryGetValue(keyDefault, out var objDefault) && objDefault is TSettings typedDefault) + return Task.FromResult(typedDefault); + + return Task.FromResult(new TSettings()); + } + + public void Set(TSettings settings, string? tenantId = null) where TSettings : class + { + _store[(typeof(TSettings), tenantId)] = settings; + } +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpNotificationProvider.cs b/Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpNotificationProvider.cs new file mode 100644 index 0000000..c8693c5 --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpNotificationProvider.cs @@ -0,0 +1,139 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ADS.Helpers.Base.Abstractions; +using ADS.Helpers.MultiTenant.Context; +using ADS.Helpers.NotifyCenter.Abstractions; +using ADS.Helpers.NotifyCenter.Models; +using MailKit.Net.Smtp; +using MailKit.Security; + +namespace ADS.Helpers.NotifyCenter.Providers.Smtp; + +public sealed class SmtpNotificationProvider : INotificationProvider +{ + private readonly INotificationSettingsProvider _settingsProvider; + private readonly ITenantContextAccessor _tenantAccessor; + private readonly IAppLogger _log; + + public NotificationChannel Channel => NotificationChannel.SmtpEmail; + public string Name => "SMTP"; + + public SmtpNotificationProvider( + INotificationSettingsProvider settingsProvider, + ITenantContextAccessor tenantAccessor, + IAppLogger log) + { + _settingsProvider = settingsProvider; + _tenantAccessor = tenantAccessor; + _log = log; + } + + public async Task SendAsync(NotificationMessage message, CancellationToken ct = default) + { + var tenant = _tenantAccessor.Current; + + if (tenant is null || string.IsNullOrWhiteSpace(tenant.TenantId)) + { + var dbg = "TenantContext is not available for SMTP provider."; + + _log.Error( + source: MessageSources.SmtpProvider, + code: NotifyCodes.TenantContextMissing, + message: dbg, + ex: null, + data: new { message.TraceId }); + + return NotificationResult.Fail( + providerName: Name, + source: MessageSources.SmtpProvider, + code: NotifyCodes.TenantContextMissing, + debugMessage: dbg); + } + + var opts = await _settingsProvider.GetAsync(tenant.TenantId, ct); + + if (string.IsNullOrWhiteSpace(opts.Host) || opts.Port <= 0 || string.IsNullOrWhiteSpace(opts.FromEmail)) + { + _log.Warn( + source: MessageSources.SmtpProvider, + code: NotifyCodes.SmtpNotConfigured, + message: "SMTP settings are missing or invalid.", + data: new { message.TraceId, tenant.TenantId }); + + return NotificationResult.Fail( + providerName: Name, + source: MessageSources.SmtpProvider, + code: NotifyCodes.SmtpNotConfigured, + debugMessage: "Host/Port/FromEmail missing or invalid."); + } + + var mime = new MimeKit.MimeMessage(); + mime.From.Add(new MimeKit.MailboxAddress(opts.FromName ?? "", opts.FromEmail)); + + foreach (var to in message.To.Distinct(StringComparer.OrdinalIgnoreCase)) + mime.To.Add(MimeKit.MailboxAddress.Parse(to)); + + foreach (var cc in message.Cc.Distinct(StringComparer.OrdinalIgnoreCase)) + mime.Cc.Add(MimeKit.MailboxAddress.Parse(cc)); + + foreach (var bcc in message.Bcc.Distinct(StringComparer.OrdinalIgnoreCase)) + mime.Bcc.Add(MimeKit.MailboxAddress.Parse(bcc)); + + mime.Subject = message.Subject ?? ""; + + var bodyBuilder = new MimeKit.BodyBuilder + { + TextBody = message.BodyText, + HtmlBody = message.BodyHtml + }; + mime.Body = bodyBuilder.ToMessageBody(); + + try + { + using var client = new SmtpClient { Timeout = opts.TimeoutMs }; + + SecureSocketOptions sec; + if (opts.UseSsl) sec = SecureSocketOptions.SslOnConnect; + else if (opts.UseStartTls) sec = SecureSocketOptions.StartTls; + else sec = SecureSocketOptions.None; + + await client.ConnectAsync(opts.Host, opts.Port, sec, ct); + + if (!string.IsNullOrWhiteSpace(opts.Username)) + await client.AuthenticateAsync(opts.Username, opts.Password, ct); + + await client.SendAsync(mime, ct); + await client.DisconnectAsync(true, ct); + + _log.Info( + source: MessageSources.SmtpProvider, + code: NotifyCodes.Ok, + message: "SMTP message sent.", + data: new { message.TraceId, tenant.TenantId }); + + return NotificationResult.Ok( + providerName: Name, + source: MessageSources.SmtpProvider, + code: NotifyCodes.Ok, + providerMessageId: null, + debugMessage: "SMTP message sent."); + } + catch (Exception ex) + { + _log.Error( + source: MessageSources.SmtpProvider, + code: NotifyCodes.SmtpSendFailed, + message: "SMTP send failed.", + ex: ex, + data: new { message.TraceId, tenant.TenantId }); + + return NotificationResult.Fail( + providerName: Name, + source: MessageSources.SmtpProvider, + code: NotifyCodes.SmtpSendFailed, + debugMessage: ex.Message); + } + } +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpOptions.cs b/Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpOptions.cs new file mode 100644 index 0000000..ed1484e --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpOptions.cs @@ -0,0 +1,19 @@ +namespace ADS.Helpers.NotifyCenter.Providers.Smtp; + +public sealed class SmtpOptions +{ + public string Host { get; set; } = ""; + public int Port { get; set; } = 587; + + public bool UseSsl { get; set; } = false; // implicit SSL (SMTPS) + public bool UseStartTls { get; set; } = true; // STARTTLS + + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + + public string FromEmail { get; set; } = ""; + public string FromName { get; set; } = ""; + + // Optional timeout + public int TimeoutMs { get; set; } = 15000; +} diff --git a/Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpSettings.cs b/Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpSettings.cs new file mode 100644 index 0000000..7c0b7a9 --- /dev/null +++ b/Helpers/ADS.Helpers.NotifyCenter/Providers/Smtp/SmtpSettings.cs @@ -0,0 +1,18 @@ +namespace ADS.Helpers.NotifyCenter.Providers.Smtp; + +public sealed class SmtpSettings +{ + public string Host { get; set; } = ""; + public int Port { get; set; } = 587; + + public bool UseSsl { get; set; } = false; + public bool UseStartTls { get; set; } = true; + + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + + public string FromEmail { get; set; } = ""; + public string FromName { get; set; } = ""; + + public int TimeoutMs { get; set; } = 15000; +} diff --git a/Helpers/ADS.Helpers.sln b/Helpers/ADS.Helpers.sln new file mode 100644 index 0000000..434efff --- /dev/null +++ b/Helpers/ADS.Helpers.sln @@ -0,0 +1,137 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Base", "ADS.Helpers.Base\ADS.Helpers.Base.csproj", "{B582C882-7C6B-4B65-8517-35335E223626}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.MultiTenant", "ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj", "{B57DB90D-2835-4BCC-9319-98F0622A6D33}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Middleware", "ADS.Helpers.Middleware\ADS.Helpers.Middleware.csproj", "{92CA3613-2F92-493E-88E3-EE24DA21CF67}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.NotifyCenter", "ADS.Helpers.NotifyCenter\ADS.Helpers.NotifyCenter.csproj", "{CBD7148D-5203-4A08-9FF1-1EE774495417}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Localization", "ADS.Helpers.Localization\ADS.Helpers.Localization.csproj", "{1B443C1C-500B-45F3-BBC0-3C95F741C976}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Tests", "tests\ADS.Helpers.Tests\ADS.Helpers.Tests.csproj", "{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Auth", "ADS.Helpers.Auth\ADS.Helpers.Auth.csproj", "{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Data", "ADS.Helpers.Data\ADS.Helpers.Data.csproj", "{0889FF69-94B0-47A7-97CB-59130D05243F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B582C882-7C6B-4B65-8517-35335E223626}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B582C882-7C6B-4B65-8517-35335E223626}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B582C882-7C6B-4B65-8517-35335E223626}.Debug|x64.ActiveCfg = Debug|Any CPU + {B582C882-7C6B-4B65-8517-35335E223626}.Debug|x64.Build.0 = Debug|Any CPU + {B582C882-7C6B-4B65-8517-35335E223626}.Debug|x86.ActiveCfg = Debug|Any CPU + {B582C882-7C6B-4B65-8517-35335E223626}.Debug|x86.Build.0 = Debug|Any CPU + {B582C882-7C6B-4B65-8517-35335E223626}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B582C882-7C6B-4B65-8517-35335E223626}.Release|Any CPU.Build.0 = Release|Any CPU + {B582C882-7C6B-4B65-8517-35335E223626}.Release|x64.ActiveCfg = Release|Any CPU + {B582C882-7C6B-4B65-8517-35335E223626}.Release|x64.Build.0 = Release|Any CPU + {B582C882-7C6B-4B65-8517-35335E223626}.Release|x86.ActiveCfg = Release|Any CPU + {B582C882-7C6B-4B65-8517-35335E223626}.Release|x86.Build.0 = Release|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x64.ActiveCfg = Debug|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x64.Build.0 = Debug|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x86.ActiveCfg = Debug|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x86.Build.0 = Debug|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|Any CPU.Build.0 = Release|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x64.ActiveCfg = Release|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x64.Build.0 = Release|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x86.ActiveCfg = Release|Any CPU + {B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x86.Build.0 = Release|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x64.ActiveCfg = Debug|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x64.Build.0 = Debug|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x86.ActiveCfg = Debug|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x86.Build.0 = Debug|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|Any CPU.Build.0 = Release|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x64.ActiveCfg = Release|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x64.Build.0 = Release|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x86.ActiveCfg = Release|Any CPU + {92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x86.Build.0 = Release|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x64.ActiveCfg = Debug|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x64.Build.0 = Debug|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x86.ActiveCfg = Debug|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x86.Build.0 = Debug|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|Any CPU.Build.0 = Release|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x64.ActiveCfg = Release|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x64.Build.0 = Release|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x86.ActiveCfg = Release|Any CPU + {CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x86.Build.0 = Release|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x64.ActiveCfg = Debug|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x64.Build.0 = Debug|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x86.Build.0 = Debug|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|Any CPU.Build.0 = Release|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x64.ActiveCfg = Release|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x64.Build.0 = Release|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x86.ActiveCfg = Release|Any CPU + {1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x86.Build.0 = Release|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x64.ActiveCfg = Debug|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x64.Build.0 = Debug|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x86.ActiveCfg = Debug|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x86.Build.0 = Debug|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|Any CPU.Build.0 = Release|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x64.ActiveCfg = Release|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x64.Build.0 = Release|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x86.ActiveCfg = Release|Any CPU + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x86.Build.0 = Release|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x64.ActiveCfg = Debug|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x64.Build.0 = Debug|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x86.ActiveCfg = Debug|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x86.Build.0 = Debug|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|Any CPU.Build.0 = Release|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x64.ActiveCfg = Release|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x64.Build.0 = Release|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x86.ActiveCfg = Release|Any CPU + {A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x86.Build.0 = Release|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x64.ActiveCfg = Debug|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x64.Build.0 = Debug|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x86.ActiveCfg = Debug|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x86.Build.0 = Debug|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Release|Any CPU.Build.0 = Release|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x64.ActiveCfg = Release|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x64.Build.0 = Release|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x86.ActiveCfg = Release|Any CPU + {0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/Helpers/tests/ADS.Helpers.Tests/ADS.Helpers.Tests.csproj b/Helpers/tests/ADS.Helpers.Tests/ADS.Helpers.Tests.csproj new file mode 100644 index 0000000..31310b0 --- /dev/null +++ b/Helpers/tests/ADS.Helpers.Tests/ADS.Helpers.Tests.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Helpers/tests/ADS.Helpers.Tests/UnitTest1.cs b/Helpers/tests/ADS.Helpers.Tests/UnitTest1.cs new file mode 100644 index 0000000..90ced33 --- /dev/null +++ b/Helpers/tests/ADS.Helpers.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace ADS.Helpers.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +}