Refactored project to module structure
This commit is contained in:
155
Moonlight.Api/Admin/Users/Users/UserAuthService.cs
Normal file
155
Moonlight.Api/Admin/Users/Users/UserAuthService.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Api.Infrastructure.Hooks;
|
||||
using Moonlight.Shared;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
public class UserAuthService
|
||||
{
|
||||
private const string UserIdClaim = "UserId";
|
||||
private const string IssuedAtClaim = "IssuedAt";
|
||||
|
||||
public const string CacheKeyPattern = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{{0}}";
|
||||
private readonly IEnumerable<IUserAuthHook> Hooks;
|
||||
private readonly HybridCache HybridCache;
|
||||
private readonly ILogger<UserAuthService> Logger;
|
||||
private readonly IOptions<UserOptions> Options;
|
||||
private readonly DatabaseRepository<User> UserRepository;
|
||||
|
||||
public UserAuthService(
|
||||
DatabaseRepository<User> userRepository,
|
||||
ILogger<UserAuthService> logger,
|
||||
IOptions<UserOptions> options,
|
||||
IEnumerable<IUserAuthHook> hooks,
|
||||
HybridCache hybridCache
|
||||
)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
Logger = logger;
|
||||
Options = options;
|
||||
Hooks = hooks;
|
||||
HybridCache = hybridCache;
|
||||
}
|
||||
|
||||
public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
|
||||
{
|
||||
if (principal is null)
|
||||
return false;
|
||||
|
||||
var username = principal.FindFirstValue(ClaimTypes.Name);
|
||||
var email = principal.FindFirstValue(ClaimTypes.Email);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
Logger.LogWarning("Unable to sync user to database as name and/or email claims are missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
// We use email as the primary identifier here
|
||||
var user = await UserRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(user => user.Email == email);
|
||||
|
||||
if (user == null) // Sync user if not already existing in the database
|
||||
{
|
||||
user = await UserRepository.AddAsync(new User
|
||||
{
|
||||
Username = username,
|
||||
Email = email,
|
||||
InvalidateTimestamp = DateTimeOffset.UtcNow.AddMinutes(-1)
|
||||
});
|
||||
}
|
||||
else // Update properties of existing user
|
||||
{
|
||||
user.Username = username;
|
||||
|
||||
await UserRepository.UpdateAsync(user);
|
||||
}
|
||||
|
||||
principal.Identities.First().AddClaims([
|
||||
new Claim(UserIdClaim, user.Id.ToString()),
|
||||
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;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateAsync(ClaimsPrincipal? principal)
|
||||
{
|
||||
// Ignore malformed claims principal
|
||||
if (principal is not { Identity.IsAuthenticated: true })
|
||||
return false;
|
||||
|
||||
var userIdString = principal.FindFirstValue(UserIdClaim);
|
||||
|
||||
if (!int.TryParse(userIdString, out var userId))
|
||||
return false;
|
||||
|
||||
var cacheKey = string.Format(CacheKeyPattern, userId);
|
||||
|
||||
var user = await HybridCache.GetOrCreateAsync<UserSession?>(
|
||||
cacheKey,
|
||||
async ct =>
|
||||
{
|
||||
return await UserRepository
|
||||
.Query()
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Id == userId)
|
||||
.Select(u => new UserSession(
|
||||
u.InvalidateTimestamp,
|
||||
u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray())
|
||||
)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
},
|
||||
new HybridCacheEntryOptions
|
||||
{
|
||||
LocalCacheExpiration = Options.Value.ValidationCacheL1Expiry,
|
||||
Expiration = Options.Value.ValidationCacheL2Expiry
|
||||
}
|
||||
);
|
||||
|
||||
if (user == null)
|
||||
return false;
|
||||
|
||||
var issuedAtString = principal.FindFirstValue(IssuedAtClaim);
|
||||
|
||||
if (!long.TryParse(issuedAtString, out var issuedAtUnix))
|
||||
return false;
|
||||
|
||||
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(issuedAtUnix).ToUniversalTime();
|
||||
|
||||
// If the issued at timestamp is greater than the token validation timestamp,
|
||||
// everything is fine. If not, it means that the token should be invalidated
|
||||
// as it is too old
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// A small model which contains data queried per session validation after the defined cache time.
|
||||
// Used for projection
|
||||
private record UserSession(DateTimeOffset InvalidateTimestamp, string[] Permissions);
|
||||
}
|
||||
52
Moonlight.Api/Admin/Users/Users/UserDeletionController.cs
Normal file
52
Moonlight.Api/Admin/Users/Users/UserDeletionController.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/users")]
|
||||
[Authorize(Policy = Permissions.Users.Delete)]
|
||||
public class UserDeletionController : Controller
|
||||
{
|
||||
private readonly DatabaseRepository<User> Repository;
|
||||
private readonly UserDeletionService UserDeletionService;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
66
Moonlight.Api/Admin/Users/Users/UserDeletionService.cs
Normal file
66
Moonlight.Api/Admin/Users/Users/UserDeletionService.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Api.Infrastructure.Hooks;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
public class UserDeletionService
|
||||
{
|
||||
private readonly IEnumerable<IUserDeletionHook> Hooks;
|
||||
private readonly HybridCache HybridCache;
|
||||
private readonly DatabaseRepository<User> Repository;
|
||||
|
||||
public UserDeletionService(
|
||||
DatabaseRepository<User> repository,
|
||||
IEnumerable<IUserDeletionHook> hooks,
|
||||
HybridCache hybridCache
|
||||
)
|
||||
{
|
||||
Repository = repository;
|
||||
Hooks = hooks;
|
||||
HybridCache = hybridCache;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
await HybridCache.RemoveAsync(string.Format(UserAuthService.CacheKeyPattern, user.Id));
|
||||
}
|
||||
}
|
||||
|
||||
public record UserDeletionValidationResult(bool IsValid, IEnumerable<string> ErrorMessages);
|
||||
40
Moonlight.Api/Admin/Users/Users/UserLogoutController.cs
Normal file
40
Moonlight.Api/Admin/Users/Users/UserLogoutController.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.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();
|
||||
}
|
||||
}
|
||||
43
Moonlight.Api/Admin/Users/Users/UserLogoutService.cs
Normal file
43
Moonlight.Api/Admin/Users/Users/UserLogoutService.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Api.Infrastructure.Hooks;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
public class UserLogoutService
|
||||
{
|
||||
private readonly IEnumerable<IUserLogoutHook> Hooks;
|
||||
private readonly HybridCache HybridCache;
|
||||
private readonly DatabaseRepository<User> Repository;
|
||||
|
||||
public UserLogoutService(
|
||||
DatabaseRepository<User> repository,
|
||||
IEnumerable<IUserLogoutHook> hooks,
|
||||
HybridCache hybridCache
|
||||
)
|
||||
{
|
||||
Repository = repository;
|
||||
Hooks = hooks;
|
||||
HybridCache = hybridCache;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
await HybridCache.RemoveAsync(string.Format(UserAuthService.CacheKeyPattern, user.Id));
|
||||
}
|
||||
}
|
||||
20
Moonlight.Api/Admin/Users/Users/UserMapper.cs
Normal file
20
Moonlight.Api/Admin/Users/Users/UserMapper.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared.Admin.Users.Users;
|
||||
using Riok.Mapperly.Abstractions;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
[Mapper]
|
||||
[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")]
|
||||
[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")]
|
||||
public static partial class UserMapper
|
||||
{
|
||||
public static partial IQueryable<UserDto> ProjectToDto(this IQueryable<User> users);
|
||||
|
||||
public static partial UserDto ToDto(User user);
|
||||
|
||||
public static partial void Merge([MappingTarget] User user, UpdateUserDto request);
|
||||
|
||||
public static partial User ToEntity(CreateUserDto request);
|
||||
}
|
||||
7
Moonlight.Api/Admin/Users/Users/UserOptions.cs
Normal file
7
Moonlight.Api/Admin/Users/Users/UserOptions.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
public class UserOptions
|
||||
{
|
||||
public TimeSpan ValidationCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
|
||||
public TimeSpan ValidationCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
|
||||
}
|
||||
113
Moonlight.Api/Admin/Users/Users/UsersController.cs
Normal file
113
Moonlight.Api/Admin/Users/Users/UsersController.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.Api.Infrastructure.Database;
|
||||
using Moonlight.Api.Infrastructure.Database.Entities;
|
||||
using Moonlight.Shared;
|
||||
using Moonlight.Shared.Admin.Users.Users;
|
||||
using Moonlight.Shared.Shared;
|
||||
|
||||
namespace Moonlight.Api.Admin.Users.Users;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/admin/users")]
|
||||
public class UsersController : Controller
|
||||
{
|
||||
private readonly DatabaseRepository<User> UserRepository;
|
||||
|
||||
public UsersController(DatabaseRepository<User> userRepository)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Policy = Permissions.Users.View)]
|
||||
public async Task<ActionResult<PagedData<UserDto>>> GetAsync(
|
||||
[FromQuery] int startIndex,
|
||||
[FromQuery] int length,
|
||||
[FromQuery] FilterOptions? filterOptions
|
||||
)
|
||||
{
|
||||
// Validation
|
||||
if (startIndex < 0)
|
||||
return Problem("Invalid start index specified", statusCode: 400);
|
||||
|
||||
if (length is < 1 or > 100)
|
||||
return Problem("Invalid length specified");
|
||||
|
||||
// Query building
|
||||
|
||||
var query = UserRepository
|
||||
.Query();
|
||||
|
||||
// Filters
|
||||
if (filterOptions != null)
|
||||
foreach (var filterOption in filterOptions.Filters)
|
||||
query = filterOption.Key switch
|
||||
{
|
||||
nameof(Infrastructure.Database.Entities.User.Email) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Email, $"%{filterOption.Value}%")),
|
||||
|
||||
nameof(Infrastructure.Database.Entities.User.Username) =>
|
||||
query.Where(user => EF.Functions.ILike(user.Username, $"%{filterOption.Value}%")),
|
||||
|
||||
_ => query
|
||||
};
|
||||
|
||||
// Pagination
|
||||
var data = await query
|
||||
.OrderBy(x => x.Id)
|
||||
.ProjectToDto()
|
||||
.Skip(startIndex)
|
||||
.Take(length)
|
||||
.ToArrayAsync();
|
||||
|
||||
var total = await query.CountAsync();
|
||||
|
||||
return new PagedData<UserDto>(data, total);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Users.View)]
|
||||
public async Task<ActionResult<UserDto>> GetAsync([FromRoute] int id)
|
||||
{
|
||||
var user = await UserRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (user == null)
|
||||
return Problem("No user with this id found", statusCode: 404);
|
||||
|
||||
return UserMapper.ToDto(user);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = Permissions.Users.Create)]
|
||||
public async Task<ActionResult<UserDto>> CreateAsync([FromBody] CreateUserDto request)
|
||||
{
|
||||
var user = UserMapper.ToEntity(request);
|
||||
user.InvalidateTimestamp = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
|
||||
var finalUser = await UserRepository.AddAsync(user);
|
||||
|
||||
return UserMapper.ToDto(finalUser);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:int}")]
|
||||
[Authorize(Policy = Permissions.Users.Edit)]
|
||||
public async Task<ActionResult<UserDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateUserDto request)
|
||||
{
|
||||
var user = await UserRepository
|
||||
.Query()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (user == null)
|
||||
return Problem("No user with this id found", statusCode: 404);
|
||||
|
||||
UserMapper.Merge(user, request);
|
||||
await UserRepository.UpdateAsync(user);
|
||||
|
||||
return UserMapper.ToDto(user);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user