From ac1c28d20d869eefec0afb7332c824832f4273a9 Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Sun, 1 Feb 2026 16:26:11 +0100 Subject: [PATCH] Implemented user logout and deletion service. Added Auth, Deletion and Logout hook. Restructed controllers --- .../Admin/UserActionsController.cs | 45 -------------- .../Admin/Users/UserDeletionController.cs | 56 +++++++++++++++++ .../Admin/Users/UserLogoutController.cs | 41 +++++++++++++ .../Admin/{ => Users}/UsersController.cs | 17 +----- Moonlight.Api/Interfaces/IUserAuthHook.cs | 13 ++++ Moonlight.Api/Interfaces/IUserDeletionHook.cs | 9 +++ Moonlight.Api/Interfaces/IUserLogoutHook.cs | 8 +++ Moonlight.Api/Services/UserAuthService.cs | 27 ++++++-- Moonlight.Api/Services/UserDeletionService.cs | 61 +++++++++++++++++++ Moonlight.Api/Services/UserLogoutService.cs | 43 +++++++++++++ Moonlight.Api/Startup/Startup.Base.cs | 7 ++- 11 files changed, 260 insertions(+), 67 deletions(-) delete mode 100644 Moonlight.Api/Http/Controllers/Admin/UserActionsController.cs create mode 100644 Moonlight.Api/Http/Controllers/Admin/Users/UserDeletionController.cs create mode 100644 Moonlight.Api/Http/Controllers/Admin/Users/UserLogoutController.cs rename Moonlight.Api/Http/Controllers/Admin/{ => Users}/UsersController.cs (87%) create mode 100644 Moonlight.Api/Interfaces/IUserAuthHook.cs create mode 100644 Moonlight.Api/Interfaces/IUserDeletionHook.cs create mode 100644 Moonlight.Api/Interfaces/IUserLogoutHook.cs create mode 100644 Moonlight.Api/Services/UserDeletionService.cs create mode 100644 Moonlight.Api/Services/UserLogoutService.cs diff --git a/Moonlight.Api/Http/Controllers/Admin/UserActionsController.cs b/Moonlight.Api/Http/Controllers/Admin/UserActionsController.cs deleted file mode 100644 index 0fbe7f00..00000000 --- a/Moonlight.Api/Http/Controllers/Admin/UserActionsController.cs +++ /dev/null @@ -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 UsersRepository; - private readonly IMemoryCache Cache; - - public UserActionsController(DatabaseRepository usersRepository, IMemoryCache cache) - { - UsersRepository = usersRepository; - Cache = cache; - } - - [HttpPost("logout")] - [Authorize(Policy = Permissions.Users.Logout)] - public async Task 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(); - } -} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/Admin/Users/UserDeletionController.cs b/Moonlight.Api/Http/Controllers/Admin/Users/UserDeletionController.cs new file mode 100644 index 00000000..48a5080f --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/Users/UserDeletionController.cs @@ -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 Repository; + + public UserDeletionController(UserDeletionService userDeletionService, DatabaseRepository repository) + { + UserDeletionService = userDeletionService; + Repository = repository; + } + + [HttpDelete("{id:int}")] + public async Task 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.Empty, + validationResult.ErrorMessages.ToArray() + } + } + ) + ); + } + + await UserDeletionService.DeleteAsync(id); + return NoContent(); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/Admin/Users/UserLogoutController.cs b/Moonlight.Api/Http/Controllers/Admin/Users/UserLogoutController.cs new file mode 100644 index 00000000..20046ba8 --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/Users/UserLogoutController.cs @@ -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 Repository; + + public UserLogoutController( + UserLogoutService logoutService, + DatabaseRepository repository + ) + { + LogoutService = logoutService; + Repository = repository; + } + + [HttpPost] + public async Task 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(); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/Admin/UsersController.cs b/Moonlight.Api/Http/Controllers/Admin/Users/UsersController.cs similarity index 87% rename from Moonlight.Api/Http/Controllers/Admin/UsersController.cs rename to Moonlight.Api/Http/Controllers/Admin/Users/UsersController.cs index f4500f3d..e90ea5ed 100644 --- a/Moonlight.Api/Http/Controllers/Admin/UsersController.cs +++ b/Moonlight.Api/Http/Controllers/Admin/Users/UsersController.cs @@ -10,7 +10,7 @@ using Moonlight.Shared.Http.Requests.Admin.Users; using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses.Admin.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 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(); - } } \ No newline at end of file diff --git a/Moonlight.Api/Interfaces/IUserAuthHook.cs b/Moonlight.Api/Interfaces/IUserAuthHook.cs new file mode 100644 index 00000000..d434e16c --- /dev/null +++ b/Moonlight.Api/Interfaces/IUserAuthHook.cs @@ -0,0 +1,13 @@ +using System.Security.Claims; +using Moonlight.Api.Database.Entities; + +namespace Moonlight.Api.Interfaces; + +public interface IUserAuthHook +{ + public Task 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 ValidateAsync(ClaimsPrincipal principal, int userId); +} \ No newline at end of file diff --git a/Moonlight.Api/Interfaces/IUserDeletionHook.cs b/Moonlight.Api/Interfaces/IUserDeletionHook.cs new file mode 100644 index 00000000..15710996 --- /dev/null +++ b/Moonlight.Api/Interfaces/IUserDeletionHook.cs @@ -0,0 +1,9 @@ +using Moonlight.Api.Database.Entities; + +namespace Moonlight.Api.Interfaces; + +public interface IUserDeletionHook +{ + public Task ValidateAsync(User user, List errors); + public Task ExecuteAsync(User user); +} \ No newline at end of file diff --git a/Moonlight.Api/Interfaces/IUserLogoutHook.cs b/Moonlight.Api/Interfaces/IUserLogoutHook.cs new file mode 100644 index 00000000..4516af69 --- /dev/null +++ b/Moonlight.Api/Interfaces/IUserLogoutHook.cs @@ -0,0 +1,8 @@ +using Moonlight.Api.Database.Entities; + +namespace Moonlight.Api.Interfaces; + +public interface IUserLogoutHook +{ + public Task ExecuteAsync(User user); +} \ No newline at end of file diff --git a/Moonlight.Api/Services/UserAuthService.cs b/Moonlight.Api/Services/UserAuthService.cs index f44b1d77..8f8d4126 100644 --- a/Moonlight.Api/Services/UserAuthService.cs +++ b/Moonlight.Api/Services/UserAuthService.cs @@ -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,21 +17,25 @@ public class UserAuthService private readonly IMemoryCache Cache; private readonly ILogger Logger; private readonly IOptions Options; + private readonly IEnumerable Hooks; private const string UserIdClaim = "UserId"; private const string IssuedAtClaim = "IssuedAt"; - + public const string CacheKeyPattern = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{{0}}"; public UserAuthService( DatabaseRepository userRepository, ILogger logger, - IMemoryCache cache, IOptions options) + IMemoryCache cache, IOptions options, + IEnumerable hooks + ) { UserRepository = userRepository; Logger = logger; Cache = cache; Options = options; + Hooks = hooks; } public async Task 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; } @@ -89,7 +100,7 @@ public class UserAuthService return false; var cacheKey = string.Format(CacheKeyPattern, userId); - + if (!Cache.TryGetValue(cacheKey, out var user)) { user = await UserRepository @@ -131,9 +142,17 @@ 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; } diff --git a/Moonlight.Api/Services/UserDeletionService.cs b/Moonlight.Api/Services/UserDeletionService.cs new file mode 100644 index 00000000..5cfb8e49 --- /dev/null +++ b/Moonlight.Api/Services/UserDeletionService.cs @@ -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 Repository; + private readonly IEnumerable Hooks; + private readonly IMemoryCache Cache; + + public UserDeletionService(DatabaseRepository repository, IEnumerable hooks, IMemoryCache cache) + { + Repository = repository; + Hooks = hooks; + Cache = cache; + } + + public async Task 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(); + + 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 ErrorMessages); \ No newline at end of file diff --git a/Moonlight.Api/Services/UserLogoutService.cs b/Moonlight.Api/Services/UserLogoutService.cs new file mode 100644 index 00000000..c630f398 --- /dev/null +++ b/Moonlight.Api/Services/UserLogoutService.cs @@ -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 Repository; + private readonly IEnumerable Hooks; + private readonly IMemoryCache Cache; + + public UserLogoutService( + DatabaseRepository repository, + IEnumerable 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)); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Startup/Startup.Base.cs b/Moonlight.Api/Startup/Startup.Base.cs index f21aa407..a71f0217 100644 --- a/Moonlight.Api/Startup/Startup.Base.cs +++ b/Moonlight.Api/Startup/Startup.Base.cs @@ -9,7 +9,7 @@ using Moonlight.Api.Helpers; using Moonlight.Api.Implementations; using Moonlight.Api.Interfaces; using Moonlight.Api.Services; -using SessionOptions = Microsoft.AspNetCore.Builder.SessionOptions; +using SessionOptions = Moonlight.Api.Configuration.SessionOptions; namespace Moonlight.Api.Startup; @@ -38,7 +38,7 @@ public partial class Startup builder.Services.AddOptions().BindConfiguration("Moonlight:Frontend"); builder.Services.AddScoped(); - + builder.Services.AddHttpClient(); builder.Services.AddOptions().BindConfiguration("Moonlight:Version"); @@ -52,6 +52,9 @@ public partial class Startup var options = provider.GetRequiredService>(); client.BaseAddress = new Uri(options.Value.IsEnabled ? options.Value.Url : "http://you-should-fail.invalid"); }); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); } private static void UseBase(WebApplication application)