diff --git a/SecurityService.BusinessLogic/RequestHandlers/GrantRequestHandler.cs b/SecurityService.BusinessLogic/RequestHandlers/GrantRequestHandler.cs new file mode 100644 index 00000000..febfd2b5 --- /dev/null +++ b/SecurityService.BusinessLogic/RequestHandlers/GrantRequestHandler.cs @@ -0,0 +1,100 @@ +using MediatR; +using OpenIddict.Abstractions; +using SecurityService.BusinessLogic.Requests; +using SecurityService.Models; +using SimpleResults; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace SecurityService.BusinessLogic.RequestHandlers; + +public sealed class GrantRequestHandler : + IRequestHandler>>, + IRequestHandler +{ + private readonly IOpenIddictAuthorizationManager _authorizationManager; + private readonly IOpenIddictApplicationManager _applicationManager; + + public GrantRequestHandler(IOpenIddictAuthorizationManager authorizationManager, IOpenIddictApplicationManager applicationManager) + { + this._authorizationManager = authorizationManager; + this._applicationManager = applicationManager; + } + + public async Task>> Handle(SecurityServiceQueries.GetUserGrantsQuery query, CancellationToken cancellationToken) + { + var authorizations = await this._authorizationManager.FindAsync(query.UserId, client: null, status: Statuses.Valid, type: null, scopes: null, cancellationToken).ToListAsync(cancellationToken); + var grants = new List(); + + foreach (var authorization in authorizations) + { + var grant = await this.BuildGrantDetailsAsync(authorization, cancellationToken); + if (grant is not null) + { + grants.Add(grant); + } + } + + var sorted = grants + .OrderByDescending(grant => grant.CreatedAt) + .ThenBy(grant => grant.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return Result.Success(sorted); + } + + private async Task BuildGrantDetailsAsync(object authorization, CancellationToken cancellationToken) + { + var authorizationId = await this._authorizationManager.GetIdAsync(authorization, cancellationToken); + if (string.IsNullOrWhiteSpace(authorizationId)) + { + return null; + } + + var applicationId = await this._authorizationManager.GetApplicationIdAsync(authorization, cancellationToken); + var (clientId, displayName) = await this.GetApplicationDisplayAsync(applicationId, cancellationToken); + + return new GrantDetails( + authorizationId, + clientId, + displayName, + await this._authorizationManager.GetScopesAsync(authorization, cancellationToken), + await this._authorizationManager.GetCreationDateAsync(authorization, cancellationToken)); + } + + private async Task<(string clientId, string displayName)> GetApplicationDisplayAsync(string? applicationId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(applicationId)) + { + return (string.Empty, string.Empty); + } + + var application = await this._applicationManager.FindByIdAsync(applicationId, cancellationToken); + if (application is null) + { + return (string.Empty, string.Empty); + } + + var clientId = await this._applicationManager.GetClientIdAsync(application, cancellationToken) ?? string.Empty; + var displayName = await this._applicationManager.GetDisplayNameAsync(application, cancellationToken) ?? clientId; + return (clientId, string.IsNullOrWhiteSpace(displayName) ? clientId : displayName); + } + + public async Task Handle(SecurityServiceCommands.RevokeGrantCommand command, CancellationToken cancellationToken) + { + var authorization = await this._authorizationManager.FindByIdAsync(command.AuthorizationId, cancellationToken); + if (authorization is null) + { + return Result.NotFound($"No authorization found with id '{command.AuthorizationId}'."); + } + + var subject = await this._authorizationManager.GetSubjectAsync(authorization, cancellationToken); + if (string.Equals(subject, command.UserId, StringComparison.Ordinal) == false) + { + return Result.NotFound($"No authorization found with id '{command.AuthorizationId}'."); + } + + return await this._authorizationManager.TryRevokeAsync(authorization, cancellationToken) + ? Result.Success() + : Result.Failure("The authorization could not be revoked."); + } +} diff --git a/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs b/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs index eca95f21..1ec4da65 100644 --- a/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs +++ b/SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs @@ -69,4 +69,5 @@ public record ProcessPasswordResetConfirmationCommand(String Username, String ClientId) : IRequest>; public sealed record LoginCommand(string Username, string Password, bool RememberLogin) : IRequest; + public sealed record RevokeGrantCommand(string UserId, string AuthorizationId) : IRequest; } diff --git a/SecurityService.BusinessLogic/Requests/SecurityServiceQueries.cs b/SecurityService.BusinessLogic/Requests/SecurityServiceQueries.cs index 0b8c54f1..7157df96 100644 --- a/SecurityService.BusinessLogic/Requests/SecurityServiceQueries.cs +++ b/SecurityService.BusinessLogic/Requests/SecurityServiceQueries.cs @@ -23,4 +23,5 @@ public sealed record GetRolesQuery() : IRequest>>; public sealed record GetUserQuery(string UserId) : IRequest>; public sealed record GetUsersQuery(string? UserName) : IRequest>>; public sealed record GetExternalProvidersQuery() : IRequest>>; + public sealed record GetUserGrantsQuery(string UserId) : IRequest>>; } diff --git a/SecurityService.UnitTests/Handlers/DeveloperHandlerTests.cs b/SecurityService.UnitTests/Handlers/DeveloperHandlerTests.cs index 69224619..5bf46d1e 100644 --- a/SecurityService.UnitTests/Handlers/DeveloperHandlerTests.cs +++ b/SecurityService.UnitTests/Handlers/DeveloperHandlerTests.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Http.HttpResults; using SecurityService.BusinessLogic; using SecurityService.Handlers; -using SecurityService.Services; using Shouldly; namespace SecurityService.UnitTests.Handlers; diff --git a/SecurityService.UnitTests/Infrastructure/IdentityMocks.cs b/SecurityService.UnitTests/Infrastructure/IdentityMocks.cs index dbb86eb8..855458bc 100644 --- a/SecurityService.UnitTests/Infrastructure/IdentityMocks.cs +++ b/SecurityService.UnitTests/Infrastructure/IdentityMocks.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.DependencyInjection; using Moq; using SecurityService.Database; -using SecurityService.Services; namespace SecurityService.UnitTests.Infrastructure; diff --git a/SecurityService.UnitTests/Pages/GrantsPageModelTests.cs b/SecurityService.UnitTests/Pages/GrantsPageModelTests.cs new file mode 100644 index 00000000..0101b0a3 --- /dev/null +++ b/SecurityService.UnitTests/Pages/GrantsPageModelTests.cs @@ -0,0 +1,129 @@ +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Moq; +using SecurityService.BusinessLogic.Requests; +using SecurityService.Database; +using SecurityService.Models; +using SecurityService.UnitTests.Infrastructure; +using Shouldly; +using SimpleResults; + +namespace SecurityService.UnitTests.Pages; + +public class GrantsPageModelTests +{ + [Fact] + public async Task OnGetAsync_WhenUserNotFound_RedirectsToLogin() + { + var userManager = IdentityMocks.CreateUserManager(); + userManager.Setup(m => m.GetUserAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + + var mediator = new Mock(); + var model = CreateModel(userManager, mediator, new DefaultHttpContext()); + + var result = await model.OnGetAsync(CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task OnGetAsync_WhenUserFound_QueriesGrantsAndReturnsPage() + { + var user = new ApplicationUser { Id = "user-1" }; + var userManager = IdentityMocks.CreateUserManager(); + userManager.Setup(m => m.GetUserAsync(It.IsAny())) + .ReturnsAsync(user); + + var grants = new List + { + new GrantDetails("auth-1", "client-1", "Client One", new[] { "openid" }, DateTimeOffset.UtcNow) + }; + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.Is(q => q.UserId == "user-1"), It.IsAny())) + .ReturnsAsync(Result.Success(grants)); + + var model = CreateModel(userManager, mediator, new DefaultHttpContext()); + + var result = await model.OnGetAsync(CancellationToken.None); + + result.ShouldBeOfType(); + model.Grants.ShouldHaveSingleItem(); + mediator.Verify(m => m.Send(It.Is(q => q.UserId == "user-1"), It.IsAny()), Times.Once); + } + + [Fact] + public async Task OnPostRevokeAsync_WhenUserNotFound_RedirectsToLogin() + { + var userManager = IdentityMocks.CreateUserManager(); + userManager.Setup(m => m.GetUserAsync(It.IsAny())) + .ReturnsAsync((ApplicationUser?)null); + + var mediator = new Mock(MockBehavior.Strict); + var model = CreateModel(userManager, mediator, new DefaultHttpContext()); + + var result = await model.OnPostRevokeAsync("auth-1", CancellationToken.None); + + result.ShouldBeOfType(); + mediator.Verify(m => m.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task OnPostRevokeAsync_WhenRevokeSucceeds_RedirectsToPage() + { + var user = new ApplicationUser { Id = "user-1" }; + var userManager = IdentityMocks.CreateUserManager(); + userManager.Setup(m => m.GetUserAsync(It.IsAny())) + .ReturnsAsync(user); + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success()); + + var model = CreateModel(userManager, mediator, new DefaultHttpContext()); + + var result = await model.OnPostRevokeAsync("auth-1", CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task OnPostRevokeAsync_WhenRevokeFails_ReturnsPageWithStatusMessage() + { + var user = new ApplicationUser { Id = "user-1" }; + var userManager = IdentityMocks.CreateUserManager(); + userManager.Setup(m => m.GetUserAsync(It.IsAny())) + .ReturnsAsync(user); + + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Failure("The authorization could not be revoked.")); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new List())); + + var model = CreateModel(userManager, mediator, new DefaultHttpContext()); + + var result = await model.OnPostRevokeAsync("auth-1", CancellationToken.None); + + result.ShouldBeOfType(); + model.StatusMessage.ShouldBe("The authorization could not be revoked."); + } + + private static SecurityService.Pages.Account.Grants.IndexModel CreateModel( + Mock> userManager, + Mock mediator, + HttpContext httpContext) + { + return new SecurityService.Pages.Account.Grants.IndexModel(userManager.Object, mediator.Object) + { + PageContext = new PageContext + { + HttpContext = httpContext + } + }; + } +} diff --git a/SecurityService.UnitTests/RequestHandlers/GrantRequestHandlerTests.cs b/SecurityService.UnitTests/RequestHandlers/GrantRequestHandlerTests.cs new file mode 100644 index 00000000..57322901 --- /dev/null +++ b/SecurityService.UnitTests/RequestHandlers/GrantRequestHandlerTests.cs @@ -0,0 +1,36 @@ +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using SecurityService.BusinessLogic.Requests; +using SecurityService.UnitTests.Infrastructure; +using Shouldly; +using SimpleResults; + +namespace SecurityService.UnitTests.RequestHandlers; + +public class GrantRequestHandlerTests +{ + [Fact] + public async Task GetUserGrants_WhenNoAuthorizations_ReturnsEmptyList() + { + using var provider = TestServiceProviderFactory.Create(nameof(this.GetUserGrants_WhenNoAuthorizations_ReturnsEmptyList)); + var mediator = provider.GetRequiredService(); + + var result = await mediator.Send(new SecurityServiceQueries.GetUserGrantsQuery("user-1")); + + result.IsSuccess.ShouldBeTrue(); + result.Data.ShouldNotBeNull(); + result.Data.ShouldBeEmpty(); + } + + [Fact] + public async Task RevokeGrant_WhenAuthorizationNotFound_ReturnsNotFound() + { + using var provider = TestServiceProviderFactory.Create(nameof(this.RevokeGrant_WhenAuthorizationNotFound_ReturnsNotFound)); + var mediator = provider.GetRequiredService(); + + var result = await mediator.Send(new SecurityServiceCommands.RevokeGrantCommand("user-1", "nonexistent-authorization-id")); + + result.IsFailed.ShouldBeTrue(); + result.Status.ShouldBe(ResultStatus.NotFound); + } +} diff --git a/SecurityService/Handlers/DeveloperHandler.cs b/SecurityService/Handlers/DeveloperHandler.cs index bd2f8095..ffaf523c 100644 --- a/SecurityService/Handlers/DeveloperHandler.cs +++ b/SecurityService/Handlers/DeveloperHandler.cs @@ -1,7 +1,6 @@ using MessagingService.Client; using MessagingService.DataTransferObjects; using SecurityService.BusinessLogic; -using SecurityService.Services; namespace SecurityService.Handlers; diff --git a/SecurityService/Pages/Account/ForgotPassword/Index.cshtml.cs b/SecurityService/Pages/Account/ForgotPassword/Index.cshtml.cs index b0faf689..f8dc5c4b 100644 --- a/SecurityService/Pages/Account/ForgotPassword/Index.cshtml.cs +++ b/SecurityService/Pages/Account/ForgotPassword/Index.cshtml.cs @@ -6,7 +6,6 @@ using SecurityService.BusinessLogic.Requests; using SecurityService.Database; using SecurityService.Pages.Account.ChangePassword; -using SecurityService.Services; using System.Collections.Specialized; using System.ComponentModel.DataAnnotations; using System.Text; diff --git a/SecurityService/Pages/Account/Grants/Index.cshtml.cs b/SecurityService/Pages/Account/Grants/Index.cshtml.cs index 132842b5..6720e83a 100644 --- a/SecurityService/Pages/Account/Grants/Index.cshtml.cs +++ b/SecurityService/Pages/Account/Grants/Index.cshtml.cs @@ -1,22 +1,23 @@ +using MediatR; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using SecurityService.BusinessLogic.Oidc; +using SecurityService.BusinessLogic.Requests; using SecurityService.Database; using SecurityService.Models; -using SecurityService.BusinessLogic.Oidc; -using SecurityService.Services; namespace SecurityService.Pages.Account.Grants; public sealed class IndexModel : PageModel { private readonly UserManager _userManager; - private readonly IGrantService _grantService; + private readonly IMediator _mediator; - public IndexModel(UserManager userManager, IGrantService grantService) + public IndexModel(UserManager userManager, IMediator mediator) { this._userManager = userManager; - this._grantService = grantService; + this._mediator = mediator; } public IReadOnlyCollection Grants { get; private set; } = Array.Empty(); @@ -31,7 +32,8 @@ public async Task OnGetAsync(CancellationToken cancellationToken) return this.Redirect($"/Account/Login?returnUrl={Uri.EscapeDataString(OidcHelpers.BuildCurrentRequestUrl(this.Request))}"); } - this.Grants = await this._grantService.GetUserGrantsAsync(user.Id, cancellationToken); + var result = await this._mediator.Send(new SecurityServiceQueries.GetUserGrantsQuery(user.Id), cancellationToken); + this.Grants = result.Data ?? new List(); return this.Page(); } @@ -43,14 +45,16 @@ public async Task OnPostRevokeAsync(string authorizationId, Cance return this.Redirect($"/Account/Login?returnUrl={Uri.EscapeDataString(OidcHelpers.BuildCurrentRequestUrl(this.Request))}"); } - var result = await this._grantService.RevokeAsync(user.Id, authorizationId, cancellationToken); + var result = await this._mediator.Send(new SecurityServiceCommands.RevokeGrantCommand(user.Id, authorizationId), cancellationToken); if (result.IsSuccess == false) { this.StatusMessage = result.Message ?? "The grant could not be revoked."; - this.Grants = await this._grantService.GetUserGrantsAsync(user.Id, cancellationToken); + var grantsResult = await this._mediator.Send(new SecurityServiceQueries.GetUserGrantsQuery(user.Id), cancellationToken); + this.Grants = grantsResult.Data ?? new List(); return this.Page(); } return this.RedirectToPage(); } } + diff --git a/SecurityService/Pages/Account/ResendConfirmationEmail/Index.cshtml.cs b/SecurityService/Pages/Account/ResendConfirmationEmail/Index.cshtml.cs index bb2c2c35..9db216e4 100644 --- a/SecurityService/Pages/Account/ResendConfirmationEmail/Index.cshtml.cs +++ b/SecurityService/Pages/Account/ResendConfirmationEmail/Index.cshtml.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.WebUtilities; using SecurityService.Database; -using SecurityService.Services; namespace SecurityService.Pages.Account.ResendConfirmationEmail; diff --git a/SecurityService/Program.cs b/SecurityService/Program.cs index dde2e84f..b001f21b 100644 --- a/SecurityService/Program.cs +++ b/SecurityService/Program.cs @@ -22,7 +22,6 @@ using SecurityService.HealthChecks; using SecurityService.HostedServices; using SecurityService.Oidc; -using SecurityService.Services; using Sentry.Extensibility; using Shared.Extensions; using Shared.General; @@ -216,7 +215,6 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddHostedService(); -builder.Services.AddScoped(); if (builder.Environment.IsEnvironment("IntegrationTest")) { builder.Services.AddHealthChecks(); diff --git a/SecurityService/SecurityService.csproj b/SecurityService/SecurityService.csproj index 753d4275..e9da776f 100644 --- a/SecurityService/SecurityService.csproj +++ b/SecurityService/SecurityService.csproj @@ -7,12 +7,10 @@ - - @@ -70,7 +68,6 @@ - diff --git a/SecurityService/Services/GrantService.cs b/SecurityService/Services/GrantService.cs deleted file mode 100644 index 25c2f3c6..00000000 --- a/SecurityService/Services/GrantService.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Microsoft.IdentityModel.Tokens; -using OpenIddict.Abstractions; -using SecurityService.Models; -using SimpleResults; -using System.IdentityModel.Tokens.Jwt; -using static OpenIddict.Abstractions.OpenIddictConstants; - -namespace SecurityService.Services; - -public interface IGrantService -{ - Task> GetUserGrantsAsync(string userId, CancellationToken cancellationToken); - - Task RevokeAsync(string userId, string authorizationId, CancellationToken cancellationToken); -} - -public sealed class GrantService : IGrantService -{ - private readonly IOpenIddictAuthorizationManager _authorizationManager; - private readonly IOpenIddictApplicationManager _applicationManager; - - public GrantService(IOpenIddictAuthorizationManager authorizationManager, IOpenIddictApplicationManager applicationManager) - { - this._authorizationManager = authorizationManager; - this._applicationManager = applicationManager; - } - - public async Task> GetUserGrantsAsync(string userId, CancellationToken cancellationToken) - { - var authorizations = await this._authorizationManager.FindAsync(userId, client: null, status: Statuses.Valid, type: null, scopes: null, cancellationToken).ToListAsync(cancellationToken); - var grants = new List(); - - foreach (var authorization in authorizations) - { - var authorizationId = await this._authorizationManager.GetIdAsync(authorization, cancellationToken); - if (string.IsNullOrWhiteSpace(authorizationId)) - { - continue; - } - - var applicationId = await this._authorizationManager.GetApplicationIdAsync(authorization, cancellationToken); - object? application = string.IsNullOrWhiteSpace(applicationId) ? null : await this._applicationManager.FindByIdAsync(applicationId, cancellationToken); - var clientId = application is null ? string.Empty : await this._applicationManager.GetClientIdAsync(application, cancellationToken) ?? string.Empty; - var displayName = application is null ? clientId : await this._applicationManager.GetDisplayNameAsync(application, cancellationToken) ?? clientId; - - grants.Add(new GrantDetails( - authorizationId, - clientId, - string.IsNullOrWhiteSpace(displayName) ? clientId : displayName, - await this._authorizationManager.GetScopesAsync(authorization, cancellationToken), - await this._authorizationManager.GetCreationDateAsync(authorization, cancellationToken))); - } - - return grants - .OrderByDescending(grant => grant.CreatedAt) - .ThenBy(grant => grant.DisplayName, StringComparer.OrdinalIgnoreCase) - .ToArray(); - } - - public async Task RevokeAsync(string userId, string authorizationId, CancellationToken cancellationToken) - { - var authorization = await this._authorizationManager.FindByIdAsync(authorizationId, cancellationToken); - if (authorization is null) - { - return Result.NotFound($"No authorization found with id '{authorizationId}'."); - } - - var subject = await this._authorizationManager.GetSubjectAsync(authorization, cancellationToken); - if (string.Equals(subject, userId, StringComparison.Ordinal) == false) - { - return Result.NotFound($"No authorization found with id '{authorizationId}'."); - } - - return await this._authorizationManager.TryRevokeAsync(authorization, cancellationToken) - ? Result.Success() - : Result.Failure("The authorization could not be revoked."); - } -} \ No newline at end of file