Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions SecurityService.BusinessLogic/RequestHandlers/GrantRequestHandler.cs
Original file line number Diff line number Diff line change
@@ -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<SecurityServiceQueries.GetUserGrantsQuery, Result<List<GrantDetails>>>,
IRequestHandler<SecurityServiceCommands.RevokeGrantCommand, Result>
{
private readonly IOpenIddictAuthorizationManager _authorizationManager;
private readonly IOpenIddictApplicationManager _applicationManager;

public GrantRequestHandler(IOpenIddictAuthorizationManager authorizationManager, IOpenIddictApplicationManager applicationManager)
{
this._authorizationManager = authorizationManager;
this._applicationManager = applicationManager;
}

public async Task<Result<List<GrantDetails>>> 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<GrantDetails>();

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<GrantDetails?> 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<Result> 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.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,5 @@ public record ProcessPasswordResetConfirmationCommand(String Username,
String ClientId) : IRequest<Result<String>>;

public sealed record LoginCommand(string Username, string Password, bool RememberLogin) : IRequest<Result>;
public sealed record RevokeGrantCommand(string UserId, string AuthorizationId) : IRequest<Result>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ public sealed record GetRolesQuery() : IRequest<Result<List<RoleDetails>>>;
public sealed record GetUserQuery(string UserId) : IRequest<Result<UserDetails>>;
public sealed record GetUsersQuery(string? UserName) : IRequest<Result<List<UserDetails>>>;
public sealed record GetExternalProvidersQuery() : IRequest<Result<List<ExternalProviderDetails>>>;
public sealed record GetUserGrantsQuery(string UserId) : IRequest<Result<List<GrantDetails>>>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using Microsoft.AspNetCore.Http.HttpResults;
using SecurityService.BusinessLogic;
using SecurityService.Handlers;
using SecurityService.Services;
using Shouldly;

namespace SecurityService.UnitTests.Handlers;
Expand Down
1 change: 0 additions & 1 deletion SecurityService.UnitTests/Infrastructure/IdentityMocks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
using Microsoft.Extensions.DependencyInjection;
using Moq;
using SecurityService.Database;
using SecurityService.Services;

namespace SecurityService.UnitTests.Infrastructure;

Expand Down
129 changes: 129 additions & 0 deletions SecurityService.UnitTests/Pages/GrantsPageModelTests.cs
Original file line number Diff line number Diff line change
@@ -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<System.Security.Claims.ClaimsPrincipal>()))
.ReturnsAsync((ApplicationUser?)null);

var mediator = new Mock<IMediator>();
var model = CreateModel(userManager, mediator, new DefaultHttpContext());

var result = await model.OnGetAsync(CancellationToken.None);

result.ShouldBeOfType<RedirectResult>();
}

[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<System.Security.Claims.ClaimsPrincipal>()))
.ReturnsAsync(user);

var grants = new List<GrantDetails>
{
new GrantDetails("auth-1", "client-1", "Client One", new[] { "openid" }, DateTimeOffset.UtcNow)
};

var mediator = new Mock<IMediator>();
mediator.Setup(m => m.Send(It.Is<SecurityServiceQueries.GetUserGrantsQuery>(q => q.UserId == "user-1"), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Success(grants));

var model = CreateModel(userManager, mediator, new DefaultHttpContext());

var result = await model.OnGetAsync(CancellationToken.None);

result.ShouldBeOfType<PageResult>();
model.Grants.ShouldHaveSingleItem();
mediator.Verify(m => m.Send(It.Is<SecurityServiceQueries.GetUserGrantsQuery>(q => q.UserId == "user-1"), It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task OnPostRevokeAsync_WhenUserNotFound_RedirectsToLogin()
{
var userManager = IdentityMocks.CreateUserManager();
userManager.Setup(m => m.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>()))
.ReturnsAsync((ApplicationUser?)null);

var mediator = new Mock<IMediator>(MockBehavior.Strict);
var model = CreateModel(userManager, mediator, new DefaultHttpContext());

var result = await model.OnPostRevokeAsync("auth-1", CancellationToken.None);

result.ShouldBeOfType<RedirectResult>();
mediator.Verify(m => m.Send(It.IsAny<SecurityServiceCommands.RevokeGrantCommand>(), It.IsAny<CancellationToken>()), 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<System.Security.Claims.ClaimsPrincipal>()))
.ReturnsAsync(user);

var mediator = new Mock<IMediator>();
mediator.Setup(m => m.Send(It.IsAny<SecurityServiceCommands.RevokeGrantCommand>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Success());

var model = CreateModel(userManager, mediator, new DefaultHttpContext());

var result = await model.OnPostRevokeAsync("auth-1", CancellationToken.None);

result.ShouldBeOfType<RedirectToPageResult>();
}

[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<System.Security.Claims.ClaimsPrincipal>()))
.ReturnsAsync(user);

var mediator = new Mock<IMediator>();
mediator.Setup(m => m.Send(It.IsAny<SecurityServiceCommands.RevokeGrantCommand>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Failure("The authorization could not be revoked."));
mediator.Setup(m => m.Send(It.IsAny<SecurityServiceQueries.GetUserGrantsQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Success(new List<GrantDetails>()));

var model = CreateModel(userManager, mediator, new DefaultHttpContext());

var result = await model.OnPostRevokeAsync("auth-1", CancellationToken.None);

result.ShouldBeOfType<PageResult>();
model.StatusMessage.ShouldBe("The authorization could not be revoked.");
}

private static SecurityService.Pages.Account.Grants.IndexModel CreateModel(
Mock<UserManager<ApplicationUser>> userManager,
Mock<IMediator> mediator,
HttpContext httpContext)
{
return new SecurityService.Pages.Account.Grants.IndexModel(userManager.Object, mediator.Object)
{
PageContext = new PageContext
{
HttpContext = httpContext
}
};
}
}
Original file line number Diff line number Diff line change
@@ -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<IMediator>();

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<IMediator>();

var result = await mediator.Send(new SecurityServiceCommands.RevokeGrantCommand("user-1", "nonexistent-authorization-id"));

result.IsFailed.ShouldBeTrue();
result.Status.ShouldBe(ResultStatus.NotFound);
}
}
1 change: 0 additions & 1 deletion SecurityService/Handlers/DeveloperHandler.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using MessagingService.Client;
using MessagingService.DataTransferObjects;
using SecurityService.BusinessLogic;
using SecurityService.Services;

namespace SecurityService.Handlers;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 12 additions & 8 deletions SecurityService/Pages/Account/Grants/Index.cshtml.cs
Original file line number Diff line number Diff line change
@@ -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<ApplicationUser> _userManager;
private readonly IGrantService _grantService;
private readonly IMediator _mediator;

public IndexModel(UserManager<ApplicationUser> userManager, IGrantService grantService)
public IndexModel(UserManager<ApplicationUser> userManager, IMediator mediator)
{
this._userManager = userManager;
this._grantService = grantService;
this._mediator = mediator;
}

public IReadOnlyCollection<GrantDetails> Grants { get; private set; } = Array.Empty<GrantDetails>();
Expand All @@ -31,7 +32,8 @@ public async Task<IActionResult> 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<GrantDetails>();
return this.Page();
}

Expand All @@ -43,14 +45,16 @@ public async Task<IActionResult> 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<GrantDetails>();
return this.Page();
}

return this.RedirectToPage();
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using SecurityService.Database;
using SecurityService.Services;

namespace SecurityService.Pages.Account.ResendConfirmationEmail;

Expand Down
2 changes: 0 additions & 2 deletions SecurityService/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -216,7 +215,6 @@
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHostedService<DatabaseInitializer>();
builder.Services.AddScoped<IGrantService, GrantService>();

if (builder.Environment.IsEnvironment("IntegrationTest")) {
builder.Services.AddHealthChecks();
Expand Down
Loading
Loading