From ae7474510856ce2c5f86923bc397132d238cc909 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 17:32:09 +0100 Subject: [PATCH] Added user management endpoints --- src/HopFrame.Api/Controller/UserController.cs | 83 ++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 6 +- src/HopFrame.Api/Logic/ILogicResult.cs | 1 + src/HopFrame.Api/Logic/IUserLogic.cs | 16 +++ .../Logic/Implementation/AuthLogic.cs | 2 +- .../Logic/Implementation/UserLogic.cs | 105 ++++++++++++++++++ src/HopFrame.Api/Models/UserCreator.cs | 8 ++ src/HopFrame.Api/Models/UserPasswordChange.cs | 6 + src/HopFrame.Database/Models/User.cs | 4 +- 9 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 src/HopFrame.Api/Controller/UserController.cs create mode 100644 src/HopFrame.Api/Logic/IUserLogic.cs create mode 100644 src/HopFrame.Api/Logic/Implementation/UserLogic.cs create mode 100644 src/HopFrame.Api/Models/UserCreator.cs create mode 100644 src/HopFrame.Api/Models/UserPasswordChange.cs diff --git a/src/HopFrame.Api/Controller/UserController.cs b/src/HopFrame.Api/Controller/UserController.cs new file mode 100644 index 0000000..6c0dd1b --- /dev/null +++ b/src/HopFrame.Api/Controller/UserController.cs @@ -0,0 +1,83 @@ +using HopFrame.Api.Logic; +using HopFrame.Api.Models; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Authorization; +using HopFrame.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace HopFrame.Api.Controller; + +[ApiController, Route("api/v1/users")] +public class UserController(IOptions permissions, IPermissionRepository perms, ITokenContext context, IUserLogic logic) : ControllerBase { + + private async Task AuthorizeRequest(string permission) { + return await perms.HasPermission(context.AccessToken, permission); + } + + [HttpGet, Authorized] + public async Task>> GetUsers() { + if (!await AuthorizeRequest(permissions.Value.Users.Read)) + return Unauthorized(); + + return await logic.GetUsers(); + } + + [HttpGet("{userId}"), Authorized] + public async Task> GetUser(string userId) { + if (!await AuthorizeRequest(permissions.Value.Users.Read)) + return Unauthorized(); + + return await logic.GetUser(userId); + } + + [HttpGet("username/{username}"), Authorized] + public async Task> GetUserByUsername(string username) { + if (!await AuthorizeRequest(permissions.Value.Users.Read)) + return Unauthorized(); + + return await logic.GetUserByUsername(username); + } + + [HttpGet("email/{email}"), Authorized] + public async Task> GetUserByEmail(string email) { + if (!await AuthorizeRequest(permissions.Value.Users.Read)) + return Unauthorized(); + + return await logic.GetUserByEmail(email); + } + + [HttpPost, Authorized] + public async Task> CreateUser([FromBody] UserCreator user) { + if (!await AuthorizeRequest(permissions.Value.Users.Create)) + return Unauthorized(); + + return await logic.CreateUser(user); + } + + [HttpPut("{userId}"), Authorized] + public async Task> UpdateUser(string userId, [FromBody] User user) { + if (!await AuthorizeRequest(permissions.Value.Users.Update)) + return Unauthorized(); + + return await logic.UpdateUser(userId, user); + } + + [HttpDelete("{userId}"), Authorized] + public async Task DeleteUser(string userId) { + if (!await AuthorizeRequest(permissions.Value.Users.Delete)) + return Unauthorized(); + + return await logic.DeleteUser(userId); + } + + [HttpPut("{userId}/password"), Authorized] + public async Task ChangePassword(string userId, [FromBody] UserPasswordChange passwordChange) { + if (context.User.Id.ToString() != userId && !await AuthorizeRequest(permissions.Value.Users.Update)) + return Unauthorized(); + + return await logic.UpdatePassword(userId, passwordChange.OldPassword, passwordChange.NewPassword); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index a596033..936231f 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -19,9 +19,10 @@ public static class ServiceCollectionExtensions { /// The configuration used to configure HopFrame authentication /// The data source for all HopFrame entities public static void AddHopFrame(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase { - var controllers = new List(); + var controllers = new List { typeof(UserController) }; - if (configuration.GetValue("HopFrame:Authentication:DefaultAuthentication")) + var defaultAuthenticationSection = configuration.GetSection("HopFrame:Authentication:DefaultAuthentication"); + if (!defaultAuthenticationSection.Exists() || configuration.GetValue("HopFrame:Authentication:DefaultAuthentication")) controllers.Add(typeof(AuthController)); if (configuration.GetValue("HopFrame:Authentication:OpenID:Enabled")) @@ -46,6 +47,7 @@ public static class ServiceCollectionExtensions { services.AddHopFrameRepositories(); services.TryAddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddHopFrameAuthentication(configuration); } diff --git a/src/HopFrame.Api/Logic/ILogicResult.cs b/src/HopFrame.Api/Logic/ILogicResult.cs index 5efb2aa..c3ff17b 100644 --- a/src/HopFrame.Api/Logic/ILogicResult.cs +++ b/src/HopFrame.Api/Logic/ILogicResult.cs @@ -1,4 +1,5 @@ using System.Net; +using Microsoft.AspNetCore.Mvc; namespace HopFrame.Api.Logic; diff --git a/src/HopFrame.Api/Logic/IUserLogic.cs b/src/HopFrame.Api/Logic/IUserLogic.cs new file mode 100644 index 0000000..42f44e9 --- /dev/null +++ b/src/HopFrame.Api/Logic/IUserLogic.cs @@ -0,0 +1,16 @@ +using HopFrame.Api.Models; +using HopFrame.Database.Models; + +namespace HopFrame.Api.Logic; + +public interface IUserLogic { + Task>> GetUsers(); + Task> GetUser(string id); + Task> GetUserByUsername(string username); + Task> GetUserByEmail(string email); + + Task> CreateUser(UserCreator user); + Task> UpdateUser(string id, User user); + Task DeleteUser(string id); + Task UpdatePassword(string id, string oldPassword, string newPassword); +} \ No newline at end of file diff --git a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs index d0d188c..444e084 100644 --- a/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs +++ b/src/HopFrame.Api/Logic/Implementation/AuthLogic.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.Options; namespace HopFrame.Api.Logic.Implementation; -internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions options) : IAuthLogic { +internal sealed class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions options) : IAuthLogic { public async Task>> Login(UserLogin login) { if (!options.Value.DefaultAuthentication) return LogicResult>.BadRequest("HopFrame authentication scheme is disabled"); diff --git a/src/HopFrame.Api/Logic/Implementation/UserLogic.cs b/src/HopFrame.Api/Logic/Implementation/UserLogic.cs new file mode 100644 index 0000000..b9db19c --- /dev/null +++ b/src/HopFrame.Api/Logic/Implementation/UserLogic.cs @@ -0,0 +1,105 @@ +using HopFrame.Api.Models; +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; +using HopFrame.Security.Claims; + +namespace HopFrame.Api.Logic.Implementation; + +internal sealed class UserLogic(IUserRepository users, ITokenContext context) : IUserLogic { + public async Task>> GetUsers() { + return LogicResult>.Ok(await users.GetUsers()); + } + + public async Task> GetUser(string id) { + if (!Guid.TryParse(id, out var userId)) + return LogicResult.BadRequest("Invalid user id"); + + var user = await users.GetUser(userId); + + if (user is null) + return LogicResult.NotFound("That user does not exist"); + + return LogicResult.Ok(user); + } + + public async Task> GetUserByUsername(string username) { + var user = await users.GetUserByUsername(username); + + if (user is null) + return LogicResult.NotFound("That user does not exist"); + + return LogicResult.Ok(user); + } + + public async Task> GetUserByEmail(string email) { + var user = await users.GetUserByEmail(email); + + if (user is null) + return LogicResult.NotFound("That user does not exist"); + + return LogicResult.Ok(user); + } + + public async Task> CreateUser(UserCreator user) { + var createdUser = new User { + Email = user.Email, + Username = user.Username, + Password = user.Password, + }; + createdUser.Permissions = user.Permissions?.Select(p => new Permission { + GrantedAt = DateTime.Now, + PermissionName = p, + User = createdUser + }).ToList(); + + var newUser = await users.AddUser(createdUser); + + if (newUser is null) + return LogicResult.Conflict("That user already exists"); + + return LogicResult.Ok(newUser); + } + + public async Task> UpdateUser(string id, User user) { + if (!Guid.TryParse(id, out var userId)) + return LogicResult.BadRequest("Invalid user id"); + + if (user.Id != userId) + return LogicResult.Conflict("Cannot edit user with different user id"); + + if (await users.GetUser(userId) is null) + return LogicResult.NotFound("That user does not exist"); + + await users.UpdateUser(user); + return LogicResult.Ok(user); + } + + public async Task DeleteUser(string id) { + if (!Guid.TryParse(id, out var userId)) + return LogicResult.BadRequest("Invalid user id"); + + var user = await users.GetUser(userId); + + if (user is null) + return LogicResult.NotFound("That user does not exist"); + + await users.DeleteUser(user); + return LogicResult.Ok(); + } + + public async Task UpdatePassword(string id, string oldPassword, string newPassword) { + if (!Guid.TryParse(id, out var userId)) + return LogicResult.BadRequest("Invalid user id"); + + var user = await users.GetUser(userId); + + if (user is null) + return LogicResult.NotFound("That user does not exist"); + + if (userId == context.User.Id && !await users.CheckUserPassword(user, oldPassword)) + return LogicResult.Conflict("Old password is not correct"); + + await users.ChangePassword(user, newPassword); + return LogicResult.Ok(); + } +} \ No newline at end of file diff --git a/src/HopFrame.Api/Models/UserCreator.cs b/src/HopFrame.Api/Models/UserCreator.cs new file mode 100644 index 0000000..9af93fb --- /dev/null +++ b/src/HopFrame.Api/Models/UserCreator.cs @@ -0,0 +1,8 @@ +namespace HopFrame.Api.Models; + +public class UserCreator { + public string Username { get; set; } + public string Email { get; set; } + public string Password { get; set; } + public virtual List Permissions { get; set; } +} \ No newline at end of file diff --git a/src/HopFrame.Api/Models/UserPasswordChange.cs b/src/HopFrame.Api/Models/UserPasswordChange.cs new file mode 100644 index 0000000..e6e183b --- /dev/null +++ b/src/HopFrame.Api/Models/UserPasswordChange.cs @@ -0,0 +1,6 @@ +namespace HopFrame.Api.Models; + +public class UserPasswordChange { + public string OldPassword { get; set; } + public string NewPassword { get; set; } +} \ No newline at end of file diff --git a/src/HopFrame.Database/Models/User.cs b/src/HopFrame.Database/Models/User.cs index feec39c..e540198 100644 --- a/src/HopFrame.Database/Models/User.cs +++ b/src/HopFrame.Database/Models/User.cs @@ -5,7 +5,7 @@ namespace HopFrame.Database.Models; public class User : IPermissionOwner { - [Key, Required, MinLength(36), MaxLength(36)] + [Key, Required] public Guid Id { get; init; } [Required, MaxLength(50)] @@ -14,7 +14,7 @@ public class User : IPermissionOwner { [Required, MaxLength(50), EmailAddress] public string Email { get; set; } - [Required, MinLength(8), MaxLength(255), JsonIgnore] + [MinLength(8), MaxLength(255), JsonIgnore] public string Password { get; set; } [Required]