diff --git a/PostFundManagement.Api/Endpoints/Users/GetRoleClaimsEndpoint.cs b/PostFundManagement.Api/Endpoints/Users/GetRoleClaimsEndpoint.cs new file mode 100644 index 0000000..15d3f6b --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Users/GetRoleClaimsEndpoint.cs @@ -0,0 +1,34 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Errors; +using PostFundManagement.Domain.Extensions; + +namespace PostFundManagement.Api.Endpoints.Users; + +[ApiVersionTarget(1)] +public sealed class GetRoleClaimsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/users/system/roles/{roleId:Guid}/claims", async (Guid roleId, HttpContext context, + IUserService service, CancellationToken cancellationToken = default) => + await service.GetRoleClaimsAsync(roleId, context.GetUserId(), cancellationToken) switch + { + { IsSuccess: true, Value: var claims } => Results.Ok(claims), + var r when r.HasError() => Results.NotFound(r.Errors.ToProblems()), + var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }) + .WithDescription("Get system role claims") + .RequireAuthorization(Constants.MainScope) + .WithName(typeof(GetRoleClaimsEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError) + .WithTags(EndpointTags.Users); + } +} diff --git a/PostFundManagement.Application/Abstractions/Services/IUserService.cs b/PostFundManagement.Application/Abstractions/Services/IUserService.cs index 537c8b4..1666db4 100644 --- a/PostFundManagement.Application/Abstractions/Services/IUserService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IUserService.cs @@ -52,6 +52,9 @@ public interface IUserService [Log] Task> GetUserClaimsAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default); + [Log] + Task> GetRoleClaimsAsync(Guid roleId, Guid userId, CancellationToken cancellationToken = default); + [Log] Task> GetSystemRolesAsync(Guid userId, CancellationToken cancellationToken = default); diff --git a/PostFundManagement.Application/Extensions/Security.cs b/PostFundManagement.Application/Extensions/Security.cs index e107b62..7bb6e8f 100644 --- a/PostFundManagement.Application/Extensions/Security.cs +++ b/PostFundManagement.Application/Extensions/Security.cs @@ -1,3 +1,4 @@ +using PostFundManagement.Application.Handlers; using PostFundManagement.Application.Interceptors; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain.Abstractions; @@ -24,19 +25,10 @@ public static class Security if (!authOptions.Authority.EndsWith('/')) authOptions.Authority += "/"; - services.AddClientCredentialsTokenManagement() - .AddClient(SkorubaCredentialClient, client => - { - client.TokenEndpoint = new Uri($"{authOptions.Authority}connect/token"); - client.ClientId = ClientId.Parse(authOptions.ClientId!); - client.ClientSecret = ClientSecret.Parse(authOptions.ClientSecret!); - client.Scope = Duende.AccessTokenManagement.Scope.ParseOrDefault(authOptions.Scope); - }); - - services.AddHttpClient(SkorubaHttpClient, client => - { - client.BaseAddress = new Uri(configuration.GetConnectionString("SkorubaApi")!); - }).AddClientCredentialsTokenHandler(ClientCredentialsClientName.Parse(SkorubaCredentialClient)); + services.AddTransient(); + + services.AddHttpClient(SkorubaHttpClient, client => client.BaseAddress = new Uri(configuration.GetConnectionString("SkorubaApi")!)) + .AddHttpMessageHandler(); services.AddScoped(); services.AddScoped(); diff --git a/PostFundManagement.Application/Handlers/ClientCredentialsTokenHandler.cs b/PostFundManagement.Application/Handlers/ClientCredentialsTokenHandler.cs new file mode 100644 index 0000000..b2ad9c0 --- /dev/null +++ b/PostFundManagement.Application/Handlers/ClientCredentialsTokenHandler.cs @@ -0,0 +1,115 @@ +using IdentityModel.Client; +using PostFundManagement.Domain.Configuration.Security; + +namespace PostFundManagement.Application.Handlers; + +public class ClientCredentialsTokenHandler(ILogger logger, + IOptions options) : DelegatingHandler +{ + private readonly MachineIdentitySettings _settings = options.Value; + private string? _cachedToken; + private DateTime _tokenExpiry = DateTime.MinValue; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + try + { + var token = await GetAccessTokenAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(token)) + { + logger.LogError("Failed to acquire access token for Skoruba API"); + + throw new InvalidOperationException("Unable to acquire access token"); + } + + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + logger.LogDebug("ClientCredentialsTokenHandler: Added bearer token to request"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error acquiring client credentials token"); + + throw; + } + + try + { + var response = await base.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("HTTP request failed with status {StatusCode} for {Method} {RequestUri}", + response.StatusCode, request.Method, request.RequestUri); + } + + return response; + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending HTTP request with client credentials token"); + + throw; + } + } + + private async Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_cachedToken) && DateTime.UtcNow < _tokenExpiry) + { + logger.LogDebug("Using cached access token"); + + return _cachedToken; + } + + logger.LogDebug("Acquiring new access token from {Authority}", _settings.Authority); + + using var tokenClient = new HttpClient(); + + try + { + if (!_settings.Authority!.EndsWith('/')) _settings.Authority += "/"; + + var scope = _settings.Scope; + + var tokenRequest = new ClientCredentialsTokenRequest + { + Address = $"{_settings.Authority}connect/token", + ClientId = _settings.ClientId!, + ClientSecret = _settings.ClientSecret, + GrantType = "client_credentials", + Scope = scope, + }; + + logger.LogDebug("Sending token request to {TokenEndpoint} for client {ClientId} with scope {Scope}", + tokenRequest.Address, tokenRequest.ClientId, tokenRequest.Scope); + + var response = await tokenClient.RequestClientCredentialsTokenAsync(tokenRequest, cancellationToken); + + if (response.IsError) + { + logger.LogError("Token request error: {Error} - {ErrorDescription}. Full response: {Raw}", + response.Error, response.ErrorDescription, response.Raw); + + return null; + } + + _cachedToken = response.AccessToken; + + var expiresIn = response.ExpiresIn > 0 ? response.ExpiresIn : 3600; + + _tokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn * 0.9); + + logger.LogDebug("Successfully acquired access token, expires at {Expiry}", _tokenExpiry); + + return _cachedToken; + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred while acquiring client credentials token"); + + throw; + } + } +} diff --git a/PostFundManagement.Application/Services/Users/UserCacheService.cs b/PostFundManagement.Application/Services/Users/UserCacheService.cs index 94605b7..cee62f5 100644 --- a/PostFundManagement.Application/Services/Users/UserCacheService.cs +++ b/PostFundManagement.Application/Services/Users/UserCacheService.cs @@ -26,6 +26,31 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach public async Task DeleteUserRoleAsync(Guid targetUserId, Guid roleId, Guid userId, CancellationToken cancellationToken = default) => await service.DeleteUserRoleAsync(targetUserId, roleId, userId, cancellationToken); + public async Task> GetRoleClaimsAsync(Guid roleId, Guid userId, CancellationToken cancellationToken = default) => + await Result.Try((Func>>)(async () => + { + if (!await featureManager.IsEnabledAsync(ServiceCachingFeature)) + return await service.GetRoleClaimsAsync(roleId, userId, cancellationToken); + + var cacheKey = HashService.ToMd5Hash($"getsystemroles-{userId}").Value; + + var cached = await cache.GetStringAsync(cacheKey, cancellationToken); + + if (string.IsNullOrWhiteSpace(cached)) + { + var claimResult = await service.GetRoleClaimsAsync(roleId, userId, cancellationToken); + + if (claimResult.IsFailed) return claimResult; + + await cache.SetStringAsync(cacheKey, claimResult.Value.ToJson(), + cacheOptions, cancellationToken); + + return claimResult; + } + + return Result.Ok(cached.FromJson()); + })); + public async Task> GetSystemRolesAsync(Guid userId, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { diff --git a/PostFundManagement.Application/Services/Users/UserService.cs b/PostFundManagement.Application/Services/Users/UserService.cs index 6eb981f..4639a68 100644 --- a/PostFundManagement.Application/Services/Users/UserService.cs +++ b/PostFundManagement.Application/Services/Users/UserService.cs @@ -321,4 +321,8 @@ public sealed class UserService(IDbContextFactory contextF return Result.OkIf(roleAddResult.IsSuccess && changedTotal > 0, ValidationError.Create("Failed to add user role.") .CausedBy(roleAddResult.Errors)); })); + + public async Task> GetRoleClaimsAsync(Guid roleId, Guid userId, CancellationToken cancellationToken = default) => + await Result.Try((Func>>)(async () => + await skorubaService.GetRoleClaimsAsync(roleId, cancellationToken))); } diff --git a/PostFundManagement.Domain/Abstractions/ISkorubaUserService.cs b/PostFundManagement.Domain/Abstractions/ISkorubaUserService.cs index 83a354e..1317e9e 100644 --- a/PostFundManagement.Domain/Abstractions/ISkorubaUserService.cs +++ b/PostFundManagement.Domain/Abstractions/ISkorubaUserService.cs @@ -46,7 +46,7 @@ public interface ISkorubaUserService Task> GetUserClaimsAsync(Guid userId, int page = 1, int pageSize = 50, CancellationToken cancellationToken = default); [Log] - Task> GetRoleClaimsAsync(Guid roleId, int page = 1, int pageSize = 50, CancellationToken cancellationToken = default); + Task> GetRoleClaimsAsync(Guid roleId, CancellationToken cancellationToken = default); [Log] Task InvalidateUserSessionAsync(Guid userId, CancellationToken cancellationToken = default); diff --git a/PostFundManagement.Domain/Configuration/Security/MachineIdentitySettings.cs b/PostFundManagement.Domain/Configuration/Security/MachineIdentitySettings.cs index a917b66..f705aa0 100644 --- a/PostFundManagement.Domain/Configuration/Security/MachineIdentitySettings.cs +++ b/PostFundManagement.Domain/Configuration/Security/MachineIdentitySettings.cs @@ -9,4 +9,4 @@ public sealed class MachineIdentitySettings public string? ClientId { get; set; } public string? ClientSecret { get; set; } -} \ No newline at end of file +} diff --git a/PostFundManagement.Domain/Models/Security/Contracts.cs b/PostFundManagement.Domain/Models/Security/Contracts.cs index e4d1260..03defb6 100644 --- a/PostFundManagement.Domain/Models/Security/Contracts.cs +++ b/PostFundManagement.Domain/Models/Security/Contracts.cs @@ -2,6 +2,39 @@ namespace PostFundManagement.Domain.Models.Security; +public sealed class UserLockOutRequest +{ + [JsonPropertyName("id")] + public string? UserId { get; set; } + + [JsonPropertyName("userName")] + public string? UserName { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("emailConfirmed")] + public bool EmailConfirmed { get; set; } + + [JsonPropertyName("phoneNumber")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("phoneNumberConfirmed")] + public bool PhoneNumberConfirmed { get; set; } + + [JsonPropertyName("LockoutEnabled")] + public bool LockoutEnabled { get; set; } + + [JsonPropertyName("twoFactorEnabled")] + public bool TwoFactorEnabled { get; set; } + + [JsonPropertyName("accessFailedCount")] + public int AccessFailedCount { get; set; } + + [JsonPropertyName("lockoutEnd")] + public DateTimeOffset? LockoutEnd { get; set; } +} + public sealed record AddUserClaimRequest([Required] Guid UserId, [Required] int ClaimId, [Required][MaxLength(LabelLength)] string ClaimType, diff --git a/PostFundManagement.Infrastructure/Security/SkorubaUserService.cs b/PostFundManagement.Infrastructure/Security/SkorubaUserService.cs index 333a886..9886324 100644 --- a/PostFundManagement.Infrastructure/Security/SkorubaUserService.cs +++ b/PostFundManagement.Infrastructure/Security/SkorubaUserService.cs @@ -49,9 +49,17 @@ public class SkorubaUserService(IHttpClientFactory httpClientFactory) : ISkoruba if (user is null) return Result.Fail(NotFoundError.Create($"User {userId} not found.")); - var updated = user with + var updated = new UserLockOutRequest { + UserId = userId.ToString(), + UserName = user.UserName, + Email = user.Email, + EmailConfirmed = user.EmailConfirmed, + PhoneNumber = user.PhoneNumber, + PhoneNumberConfirmed = user.PhoneNumberConfirmed, LockoutEnabled = true, + TwoFactorEnabled = user.TwoFactorEnabled, + AccessFailedCount = user.AccessFailedCount, LockoutEnd = disabled ? DateTimeOffset.UtcNow.AddYears(100) : null }; @@ -97,7 +105,7 @@ public class SkorubaUserService(IHttpClientFactory httpClientFactory) : ISkoruba var httpClient = httpClientFactory.CreateClient(SkorubaHttpClient); var payload = new { UserId = userId, RoleId = roleId }; - var response = await httpClient.PostAsJsonAsync($"api/users/{userId}/roles", payload, cancellationToken); + var response = await httpClient.PostAsJsonAsync($"api/users/roles", payload, cancellationToken); return Result.OkIf(response.IsSuccessStatusCode, CreateError.Create("Failed to add user role.")); })); @@ -106,7 +114,17 @@ public class SkorubaUserService(IHttpClientFactory httpClientFactory) : ISkoruba await Result.Try((Func>)(async () => { var httpClient = httpClientFactory.CreateClient(SkorubaHttpClient); - var response = await httpClient.DeleteAsync($"api/users/{userId}/roles/{roleId}", cancellationToken); + + var request = new HttpRequestMessage(HttpMethod.Delete, "api/Users/Roles") + { + Content = JsonContent.Create(new + { + userId = userId.ToString(), + roleId = roleId.ToString() + }) + }; + + var response = await httpClient.SendAsync(request, cancellationToken); return Result.OkIf(response.IsSuccessStatusCode, CreateError.Create("Failed to remove user role.")); })); @@ -116,17 +134,20 @@ public class SkorubaUserService(IHttpClientFactory httpClientFactory) : ISkoruba { var httpClient = httpClientFactory.CreateClient(SkorubaHttpClient); - var result = await httpClient.GetFromJsonAsync($"api/users/{userId}/roles?page={page}&pageSize={pageSize}", cancellationToken); + var result = await httpClient.GetFromJsonAsync($"api/Users/{userId}/Roles?page={page}&pageSize={pageSize}", cancellationToken); return Result.FailIf(result is null, NotFoundError.Create($"Roles for user {userId} could not be retrieved.")) .ToResult(result?.Roles?.ToArray() ?? []); })); - public async Task> GetRoleClaimsAsync(Guid roleId, int page = 1, int pageSize = 50, CancellationToken cancellationToken = default) => + public async Task> GetRoleClaimsAsync(Guid roleId, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { var httpClient = httpClientFactory.CreateClient(SkorubaHttpClient); + var page = 1; + var pageSize = 100; + var result = await httpClient.GetFromJsonAsync($"api/roles/{roleId}/claims?page={page}&pageSize={pageSize}", cancellationToken); return Result.FailIf(result is null, NotFoundError.Create($"Claims for role {roleId} could not be retrieved.")) diff --git a/PostFundManagement.Tests/SkorubaUserServiceIntegrationTests.cs b/PostFundManagement.Tests/SkorubaUserServiceIntegrationTests.cs index f216b8e..6df913d 100644 --- a/PostFundManagement.Tests/SkorubaUserServiceIntegrationTests.cs +++ b/PostFundManagement.Tests/SkorubaUserServiceIntegrationTests.cs @@ -74,14 +74,14 @@ public class SkorubaUserServiceGetUserRolesIntegrationTests { // Arrange var userId = Guid.Parse("6059f1e4-6a41-4447-a546-86ece5102004"); - var page = 2; + var page = 1; var pageSize = 25; // Act var result = await _skorubaService.GetUserRolesAsync(userId, page, pageSize); // Assert - Assert.True(result.IsSuccess, result.Errors[0].Message); + Assert.True(result.IsSuccess, result.Errors.FirstOrDefault()?.Message); Assert.Empty(result.Errors); }