From ae7474510856ce2c5f86923bc397132d238cc909 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 17:32:09 +0100 Subject: [PATCH 1/2] 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] -- 2.49.1 From 4aab0112248ad679fe6f486e4bb9e2b440802cb3 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 22 Dec 2024 18:08:05 +0100 Subject: [PATCH 2/2] Fixed Database update problem + added group management endpoints --- .../Controller/GroupController.cs | 74 +++++++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 3 +- src/HopFrame.Api/Logic/IGroupLogic.cs | 14 ++++ .../Logic/Implementation/GroupLogic.cs | 66 +++++++++++++++++ .../Implementation/GroupRepository.cs | 31 ++++++-- .../Implementation/UserRepository.cs | 39 +++++++++- 6 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 src/HopFrame.Api/Controller/GroupController.cs create mode 100644 src/HopFrame.Api/Logic/IGroupLogic.cs create mode 100644 src/HopFrame.Api/Logic/Implementation/GroupLogic.cs diff --git a/src/HopFrame.Api/Controller/GroupController.cs b/src/HopFrame.Api/Controller/GroupController.cs new file mode 100644 index 0000000..fdfbc07 --- /dev/null +++ b/src/HopFrame.Api/Controller/GroupController.cs @@ -0,0 +1,74 @@ +using HopFrame.Api.Logic; +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/groups")] +public class GroupController(IOptions permissions, IPermissionRepository perms, ITokenContext context, IGroupLogic groups) : ControllerBase { + + private async Task AuthorizeRequest(string permission) { + return await perms.HasPermission(context.AccessToken, permission); + } + + [HttpGet, Authorized] + public async Task>> GetGroups() { + if (!await AuthorizeRequest(permissions.Value.Groups.Read)) + return Unauthorized(); + + return await groups.GetGroups(); + } + + [HttpGet("default"), Authorized] + public async Task>> GetDefaultGroups() { + if (!await AuthorizeRequest(permissions.Value.Groups.Read)) + return Unauthorized(); + + return await groups.GetDefaultGroups(); + } + + [HttpGet("user/{userId}"), Authorized] + public async Task>> GetUserGroups(string userId) { + if (!await AuthorizeRequest(permissions.Value.Groups.Read)) + return Unauthorized(); + + return await groups.GetUserGroups(userId); + } + + [HttpGet("{name}"), Authorized] + public async Task> GetGroup(string name) { + if (!await AuthorizeRequest(permissions.Value.Groups.Read)) + return Unauthorized(); + + return await groups.GetGroup(name); + } + + [HttpPost, Authorized] + public async Task> CreateGroup([FromBody] PermissionGroup group) { + if (!await AuthorizeRequest(permissions.Value.Groups.Create)) + return Unauthorized(); + + return await groups.CreateGroup(group); + } + + [HttpPut, Authorized] + public async Task> UpdateGroup([FromBody] PermissionGroup group) { + if (!await AuthorizeRequest(permissions.Value.Groups.Update)) + return Unauthorized(); + + return await groups.UpdateGroup(group); + } + + [HttpDelete("{name}"), Authorized] + public async Task DeleteGroup(string name) { + if (!await AuthorizeRequest(permissions.Value.Groups.Delete)) + return Unauthorized(); + + return await groups.DeleteGroup(name); + } + +} \ No newline at end of file diff --git a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs index 936231f..19436eb 100644 --- a/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/HopFrame.Api/Extensions/ServiceCollectionExtensions.cs @@ -19,7 +19,7 @@ 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 { typeof(UserController) }; + var controllers = new List { typeof(UserController), typeof(GroupController) }; var defaultAuthenticationSection = configuration.GetSection("HopFrame:Authentication:DefaultAuthentication"); if (!defaultAuthenticationSection.Exists() || configuration.GetValue("HopFrame:Authentication:DefaultAuthentication")) @@ -48,6 +48,7 @@ public static class ServiceCollectionExtensions { services.TryAddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHopFrameAuthentication(configuration); } diff --git a/src/HopFrame.Api/Logic/IGroupLogic.cs b/src/HopFrame.Api/Logic/IGroupLogic.cs new file mode 100644 index 0000000..48bdd45 --- /dev/null +++ b/src/HopFrame.Api/Logic/IGroupLogic.cs @@ -0,0 +1,14 @@ +using HopFrame.Database.Models; + +namespace HopFrame.Api.Logic; + +public interface IGroupLogic { + Task>> GetGroups(); + Task>> GetDefaultGroups(); + Task>> GetUserGroups(string userId); + Task> GetGroup(string name); + + Task> CreateGroup(PermissionGroup group); + Task> UpdateGroup(PermissionGroup group); + Task DeleteGroup(string name); +} \ No newline at end of file diff --git a/src/HopFrame.Api/Logic/Implementation/GroupLogic.cs b/src/HopFrame.Api/Logic/Implementation/GroupLogic.cs new file mode 100644 index 0000000..dd3e5b7 --- /dev/null +++ b/src/HopFrame.Api/Logic/Implementation/GroupLogic.cs @@ -0,0 +1,66 @@ +using HopFrame.Database.Models; +using HopFrame.Database.Repositories; + +namespace HopFrame.Api.Logic.Implementation; + +internal sealed class GroupLogic(IGroupRepository groups, IUserRepository users) : IGroupLogic { + public async Task>> GetGroups() { + return LogicResult>.Ok(await groups.GetPermissionGroups()); + } + + public async Task>> GetDefaultGroups() { + return LogicResult>.Ok(await groups.GetDefaultGroups()); + } + + public async Task>> GetUserGroups(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(await groups.GetUserGroups(user)); + } + + public async Task> GetGroup(string name) { + var group = await groups.GetPermissionGroup(name); + + if (group is null) + return LogicResult.NotFound("That group does not exist"); + + return LogicResult.Ok(group); + } + + public async Task> CreateGroup(PermissionGroup group) { + if (group is null) + return LogicResult.BadRequest("Provide a group"); + + if (!group.Name.StartsWith("group.")) + return LogicResult.BadRequest("Group names must start with 'group.'"); + + if (await groups.GetPermissionGroup(group.Name) != null) + return LogicResult.Conflict("That group already exists"); + + return LogicResult.Ok(await groups.CreatePermissionGroup(group)); + } + + public async Task> UpdateGroup(PermissionGroup group) { + if (await groups.GetPermissionGroup(group.Name) == null) + return LogicResult.NotFound("That user does not exist"); + + await groups.EditPermissionGroup(group); + return LogicResult.Ok(group); + } + + public async Task DeleteGroup(string name) { + var group = await groups.GetPermissionGroup(name); + + if (group is null) + return LogicResult.NotFound("That group does not exist"); + + await groups.DeletePermissionGroup(group); + return LogicResult.Ok(); + } +} \ No newline at end of file diff --git a/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs b/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs index 547e193..b190ce6 100644 --- a/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/GroupRepository.cs @@ -33,19 +33,38 @@ internal sealed class GroupRepository(TDbContext context) : IGroupRe } public async Task EditPermissionGroup(PermissionGroup group) { - var orig = await context.Groups.SingleOrDefaultAsync(g => g.Name == group.Name); - + var orig = await context.Groups + .Include(g => g.Permissions) // Include related entities + .SingleOrDefaultAsync(g => g.Name == group.Name); + if (orig is null) return; - var entity = context.Groups.Update(orig); + // Update the main entity's properties + orig.IsDefaultGroup = group.IsDefaultGroup; + orig.Description = group.Description; - entity.Entity.IsDefaultGroup = group.IsDefaultGroup; - entity.Entity.Description = group.Description; - entity.Entity.Permissions = group.Permissions; + // Update the permissions + foreach (var permission in group.Permissions) { + var existingPermission = orig.Permissions.FirstOrDefault(p => p.Id == permission.Id); + if (existingPermission != null) { + // Update existing permission + context.Entry(existingPermission).CurrentValues.SetValues(permission); + } else { + // Add new permission + orig.Permissions.Add(permission); + } + } + + // Remove deleted permissions + foreach (var permission in orig.Permissions.ToList().Where(permission => group.Permissions.All(p => p.Id != permission.Id))) { + orig.Permissions.Remove(permission); + context.Permissions.Remove(permission); // Ensure it gets removed from the database + } await context.SaveChangesAsync(); } + public async Task CreatePermissionGroup(PermissionGroup group) { group.CreatedAt = DateTime.Now; await context.Groups.AddAsync(group); diff --git a/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs b/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs index c642466..3e4e1b8 100644 --- a/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs +++ b/src/HopFrame.Database/Repositories/Implementation/UserRepository.cs @@ -69,10 +69,45 @@ internal sealed class UserRepository(TDbContext context, IGroupRepos .SingleOrDefaultAsync(entry => entry.Id == user.Id); if (entry is null) return; + // Update the main entity's properties entry.Email = user.Email; entry.Username = user.Username; - entry.Permissions = user.Permissions; - entry.Tokens = user.Tokens; + + // Update Permissions + foreach (var permission in user.Permissions) { + var existingPermission = entry.Permissions.FirstOrDefault(p => p.Id == permission.Id); + if (existingPermission != null) { + // Update existing permission + context.Entry(existingPermission).CurrentValues.SetValues(permission); + } else { + // Add new permission + entry.Permissions.Add(permission); + } + } + + // Remove deleted permissions + foreach (var permission in entry.Permissions.ToList().Where(permission => user.Permissions.All(p => p.Id != permission.Id))) { + entry.Permissions.Remove(permission); + context.Permissions.Remove(permission); // Ensure it gets removed from the database + } + + // Update Tokens + foreach (var token in user.Tokens) { + var existingToken = entry.Tokens.FirstOrDefault(t => t.TokenId == token.TokenId); + if (existingToken != null) { + // Update existing token + context.Entry(existingToken).CurrentValues.SetValues(token); + } else { + // Add new token + entry.Tokens.Add(token); + } + } + + // Remove deleted tokens + foreach (var token in entry.Tokens.ToList().Where(token => user.Tokens.All(t => t.TokenId != token.TokenId))) { + entry.Tokens.Remove(token); + context.Tokens.Remove(token); // Ensure it gets removed from the database + } await context.SaveChangesAsync(); } -- 2.49.1