2 Commits

12 changed files with 270 additions and 69 deletions

View File

@@ -1,45 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Services;
using Moonlight.Shared;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/users/{id:int}")]
public class UserActionsController : Controller
{
// Consider building a service for deletion and logout or actions in general
private readonly DatabaseRepository<User> UsersRepository;
private readonly IMemoryCache Cache;
public UserActionsController(DatabaseRepository<User> usersRepository, IMemoryCache cache)
{
UsersRepository = usersRepository;
Cache = cache;
}
[HttpPost("logout")]
[Authorize(Policy = Permissions.Users.Logout)]
public async Task<ActionResult> LogoutAsync([FromRoute] int id)
{
var user = await UsersRepository
.Query()
.FirstOrDefaultAsync(u => u.Id == id);
if(user == null)
return Problem("User not found", statusCode: 404);
user.InvalidateTimestamp = DateTimeOffset.UtcNow;
await UsersRepository.UpdateAsync(user);
Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, id));
return NoContent();
}
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Frozen;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Services;
using Moonlight.Shared;
namespace Moonlight.Api.Http.Controllers.Admin.Users;
[ApiController]
[Route("api/admin/users")]
[Authorize(Policy = Permissions.Users.Delete)]
public class UserDeletionController : Controller
{
private readonly UserDeletionService UserDeletionService;
private readonly DatabaseRepository<User> Repository;
public UserDeletionController(UserDeletionService userDeletionService, DatabaseRepository<User> repository)
{
UserDeletionService = userDeletionService;
Repository = repository;
}
[HttpDelete("{id:int}")]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var userExists = await Repository
.Query()
.AnyAsync(user => user.Id == id);
if (!userExists)
return Problem("No user with this id found", statusCode: 404);
var validationResult = await UserDeletionService.ValidateAsync(id);
if (!validationResult.IsValid)
{
return ValidationProblem(
new ValidationProblemDetails(
new Dictionary<string, string[]>()
{
{
string.Empty,
validationResult.ErrorMessages.ToArray()
}
}
)
);
}
await UserDeletionService.DeleteAsync(id);
return NoContent();
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Services;
using Moonlight.Shared;
namespace Moonlight.Api.Http.Controllers.Admin.Users;
[ApiController]
[Route("api/admin/users/{id:int}/logout")]
[Authorize(Policy = Permissions.Users.Logout)]
public class UserLogoutController : Controller
{
private readonly UserLogoutService LogoutService;
private readonly DatabaseRepository<User> Repository;
public UserLogoutController(
UserLogoutService logoutService,
DatabaseRepository<User> repository
)
{
LogoutService = logoutService;
Repository = repository;
}
[HttpPost]
public async Task<ActionResult> LogoutAsync([FromRoute] int id)
{
var userExists = await Repository
.Query()
.AnyAsync(user => user.Id == id);
if (!userExists)
return Problem("No user with this id found", statusCode: 404);
await LogoutService.LogoutAsync(id);
return NoContent();
}
}

View File

@@ -10,7 +10,7 @@ using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Api.Http.Controllers.Admin;
namespace Moonlight.Api.Http.Controllers.Admin.Users;
[Authorize]
[ApiController]
@@ -117,19 +117,4 @@ public class UsersController : Controller
return UserMapper.ToDto(user);
}
[HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.Users.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{
var user = await UserRepository
.Query()
.FirstOrDefaultAsync(user => user.Id == id);
if (user == null)
return Problem("No user with this id found", statusCode: 404);
await UserRepository.RemoveAsync(user);
return NoContent();
}
}

View File

@@ -0,0 +1,13 @@
using System.Security.Claims;
using Moonlight.Api.Database.Entities;
namespace Moonlight.Api.Interfaces;
public interface IUserAuthHook
{
public Task<bool> SyncAsync(ClaimsPrincipal principal, User trackedUser);
// Every implementation of this function should execute as fast as possible
// as this directly impacts every api call
public Task<bool> ValidateAsync(ClaimsPrincipal principal, int userId);
}

View File

@@ -0,0 +1,9 @@
using Moonlight.Api.Database.Entities;
namespace Moonlight.Api.Interfaces;
public interface IUserDeletionHook
{
public Task<bool> ValidateAsync(User user, List<string> errors);
public Task ExecuteAsync(User user);
}

View File

@@ -0,0 +1,8 @@
using Moonlight.Api.Database.Entities;
namespace Moonlight.Api.Interfaces;
public interface IUserLogoutHook
{
public Task ExecuteAsync(User user);
}

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
using Moonlight.Shared;
namespace Moonlight.Api.Services;
@@ -16,6 +17,7 @@ public class UserAuthService
private readonly IMemoryCache Cache;
private readonly ILogger<UserAuthService> Logger;
private readonly IOptions<SessionOptions> Options;
private readonly IEnumerable<IUserAuthHook> Hooks;
private const string UserIdClaim = "UserId";
private const string IssuedAtClaim = "IssuedAt";
@@ -25,12 +27,15 @@ public class UserAuthService
public UserAuthService(
DatabaseRepository<User> userRepository,
ILogger<UserAuthService> logger,
IMemoryCache cache, IOptions<SessionOptions> options)
IMemoryCache cache, IOptions<SessionOptions> options,
IEnumerable<IUserAuthHook> hooks
)
{
UserRepository = userRepository;
Logger = logger;
Cache = cache;
Options = options;
Hooks = hooks;
}
public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
@@ -50,7 +55,6 @@ public class UserAuthService
// We use email as the primary identifier here
var user = await UserRepository
.Query()
.AsNoTracking()
.FirstOrDefaultAsync(user => user.Email == email);
if (user == null) // Sync user if not already existing in the database
@@ -74,6 +78,13 @@ public class UserAuthService
new Claim(IssuedAtClaim, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())
]);
foreach (var hook in Hooks)
{
// Run every hook and if any returns false we return false as well
if(!await hook.SyncAsync(principal, user))
return false;
}
return true;
}
@@ -131,10 +142,18 @@ public class UserAuthService
if (issuedAt < user.InvalidateTimestamp)
return false;
// Load every permission as claim
principal.Identities.First().AddClaims(
user.Permissions.Select(x => new Claim(Permissions.ClaimType, x))
);
foreach (var hook in Hooks)
{
// Run every hook and if any returns false we return false as well
if(!await hook.ValidateAsync(principal, userId))
return false;
}
return true;
}

View File

@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
namespace Moonlight.Api.Services;
public class UserDeletionService
{
private readonly DatabaseRepository<User> Repository;
private readonly IEnumerable<IUserDeletionHook> Hooks;
private readonly IMemoryCache Cache;
public UserDeletionService(DatabaseRepository<User> repository, IEnumerable<IUserDeletionHook> hooks, IMemoryCache cache)
{
Repository = repository;
Hooks = hooks;
Cache = cache;
}
public async Task<UserDeletionValidationResult> ValidateAsync(int userId)
{
var user = await Repository
.Query()
.FirstOrDefaultAsync(x => x.Id == userId);
if(user == null)
throw new AggregateException($"User with id {userId} not found");
var errorMessages = new List<string>();
foreach (var hook in Hooks)
{
if (await hook.ValidateAsync(user, errorMessages))
continue;
return new UserDeletionValidationResult(false, errorMessages);
}
return new UserDeletionValidationResult(true, []);
}
public async Task DeleteAsync(int userId)
{
var user = await Repository
.Query()
.FirstOrDefaultAsync(x => x.Id == userId);
if(user == null)
throw new AggregateException($"User with id {userId} not found");
foreach (var hook in Hooks)
await hook.ExecuteAsync(user);
await Repository.RemoveAsync(user);
Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, userId));
}
}
public record UserDeletionValidationResult(bool IsValid, IEnumerable<string> ErrorMessages);

View File

@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
namespace Moonlight.Api.Services;
public class UserLogoutService
{
private readonly DatabaseRepository<User> Repository;
private readonly IEnumerable<IUserLogoutHook> Hooks;
private readonly IMemoryCache Cache;
public UserLogoutService(
DatabaseRepository<User> repository,
IEnumerable<IUserLogoutHook> hooks,
IMemoryCache cache
)
{
Repository = repository;
Hooks = hooks;
Cache = cache;
}
public async Task LogoutAsync(int userId)
{
var user = await Repository
.Query()
.FirstOrDefaultAsync(x => x.Id == userId);
if(user == null)
throw new AggregateException($"User with id {userId} not found");
foreach (var hook in Hooks)
await hook.ExecuteAsync(user);
user.InvalidateTimestamp = DateTimeOffset.UtcNow;
await Repository.UpdateAsync(user);
Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, userId));
}
}

View File

@@ -43,6 +43,9 @@ public partial class Startup
builder.Services.AddOptions<VersionOptions>().BindConfiguration("Moonlight:Version");
builder.Services.AddSingleton<VersionService>();
builder.Services.AddScoped<UserDeletionService>();
builder.Services.AddScoped<UserLogoutService>();
}
private static void UseBase(WebApplication application)

View File

@@ -38,10 +38,18 @@
<div class="mt-3">
<DataGrid @ref="Grid" TGridItem="UserDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" Field="u => u.Id"/>
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true"
Identifier="@nameof(UserDto.Username)" Field="u => u.Username"/>
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true"
<PropertyColumn HeadClassName="text-left" Field="u => u.Id"/>
<TemplateColumn IsFilterable="true" Identifier="@nameof(UserDto.Username)" Title="Username">
<CellTemplate>
<TableCell>
<a class="text-primary" href="#"
@onclick="() => EditAsync(context)" @onclick:preventDefault>
@context.Username
</a>
</TableCell>
</CellTemplate>
</TemplateColumn>
<PropertyColumn HeadClassName="text-left" IsFilterable="true"
Identifier="@nameof(UserDto.Email)" Field="u => u.Email"/>
<TemplateColumn>
<CellTemplate>