From df570b1ac11e4da3604db93b18a04b8a9f596a22 Mon Sep 17 00:00:00 2001 From: VPKSoft Date: Sat, 22 Mar 2025 17:52:21 +0200 Subject: [PATCH 1/6] Refactor routes to different controllers. --- PasswordKeeper.BusinessLogic/Users.cs | 6 + PasswordKeeper.Classes/DatabaseUtilities.cs | 18 ++ PasswordKeeper.DataAccess/Users.cs | 11 ++ .../PasswordKeeper.DatabaseMigrations.csproj | 1 + PasswordKeeper.DatabaseMigrations/Program.cs | 4 +- .../Controllers/AuthenticationController.cs | 145 ---------------- .../Controllers/UsersController.cs | 161 ++++++++++++++++++ PasswordKeeper.Tests/ControllerTests.cs | 9 +- PasswordKeeper.Tests/Helpers.cs | 3 +- PasswordKeeper.Tests/MockDbContextFactory.cs | 3 +- 10 files changed, 209 insertions(+), 152 deletions(-) create mode 100644 PasswordKeeper.Classes/DatabaseUtilities.cs create mode 100644 PasswordKeeper.Server/Controllers/UsersController.cs diff --git a/PasswordKeeper.BusinessLogic/Users.cs b/PasswordKeeper.BusinessLogic/Users.cs index b4b6399..9b63862 100644 --- a/PasswordKeeper.BusinessLogic/Users.cs +++ b/PasswordKeeper.BusinessLogic/Users.cs @@ -34,6 +34,12 @@ public async Task UsersExist(bool? admin = null) { return await users.UsersExist(admin); } + + /// + public async Task> GetAllUsers() + { + return await users.GetAllUsers(); + } private const int IterationCount = 1000000; diff --git a/PasswordKeeper.Classes/DatabaseUtilities.cs b/PasswordKeeper.Classes/DatabaseUtilities.cs new file mode 100644 index 0000000..762f23d --- /dev/null +++ b/PasswordKeeper.Classes/DatabaseUtilities.cs @@ -0,0 +1,18 @@ +namespace PasswordKeeper.Classes; + +/// +/// Database utilities. +/// +public static class DatabaseUtilities +{ + /// + /// Gets the connection string for a SQLite database. + /// + /// The name of the database. + /// The connection string. + // ReSharper disable once InconsistentNaming (this is how SQLite is written) + public static string GetSQLiteConnectionString(string databaseName) + { + return $"Data Source=./{databaseName}.db;Pooling=False;"; + } +} \ No newline at end of file diff --git a/PasswordKeeper.DataAccess/Users.cs b/PasswordKeeper.DataAccess/Users.cs index 070b324..2942264 100644 --- a/PasswordKeeper.DataAccess/Users.cs +++ b/PasswordKeeper.DataAccess/Users.cs @@ -83,4 +83,15 @@ public async Task UsersExist(bool? admin = null) return mapper.Map(user); } + + /// + /// Gets all users. + /// + /// A collection of all users. + public async Task> GetAllUsers() + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + return mapper.Map>(await context.Users.ToListAsync()); + } } \ No newline at end of file diff --git a/PasswordKeeper.DatabaseMigrations/PasswordKeeper.DatabaseMigrations.csproj b/PasswordKeeper.DatabaseMigrations/PasswordKeeper.DatabaseMigrations.csproj index 7607de5..fc78c10 100644 --- a/PasswordKeeper.DatabaseMigrations/PasswordKeeper.DatabaseMigrations.csproj +++ b/PasswordKeeper.DatabaseMigrations/PasswordKeeper.DatabaseMigrations.csproj @@ -18,6 +18,7 @@ + diff --git a/PasswordKeeper.DatabaseMigrations/Program.cs b/PasswordKeeper.DatabaseMigrations/Program.cs index 5e8d164..6e9d1e6 100644 --- a/PasswordKeeper.DatabaseMigrations/Program.cs +++ b/PasswordKeeper.DatabaseMigrations/Program.cs @@ -2,6 +2,8 @@ using FluentMigrator.Runner; using Microsoft.Extensions.DependencyInjection; using McMaster.Extensions.CommandLineUtils; +using PasswordKeeper.Classes; + // ReSharper disable MemberCanBePrivate.Global namespace PasswordKeeper.DatabaseMigrations; @@ -51,7 +53,7 @@ public void OnExecute() if (TestDbName != null) { // NOTE: Pooling=False is required for SQLite for the database file to be released after migrations! - connectionString = $"Data Source=./{TestDbName}.db;Pooling=False;"; + connectionString = DatabaseUtilities.GetSQLiteConnectionString(TestDbName); IsTestDb = true; } else diff --git a/PasswordKeeper.Server/Controllers/AuthenticationController.cs b/PasswordKeeper.Server/Controllers/AuthenticationController.cs index d80c528..c7da460 100644 --- a/PasswordKeeper.Server/Controllers/AuthenticationController.cs +++ b/PasswordKeeper.Server/Controllers/AuthenticationController.cs @@ -20,14 +20,6 @@ public class AuthenticationController(Users users) : ControllerBase /// The username for the login. /// The password for the login. public record UserLogin(string Username, string Password); - - /// - /// User change data. - /// - /// The ID of the user to change. - /// The new username for the user. - /// The new password for the user. - public record UserChangeRequest(int UserId, string Username, string Password); /// /// Logs the user in and returns a JWT token. @@ -52,143 +44,6 @@ public async Task Login([FromBody] UserLogin user) return Unauthorized(); } - - - /// - /// Checks a password against the password complexity requirements. - /// - /// The password to check. - /// Ok if the password meets the complexity requirements, otherwise BadRequest with an error message. - [Route(nameof(PasswordOk))] - [HttpPost] - public IActionResult PasswordOk([FromBody] string password) - { - var result = Passwords.IsPasswordOk(password, out var message, out _); - return result ? Ok() : BadRequest(message); - } - - /// - /// Updates the user's password if the requester is authorized and the password meets the complexity requirements. - /// - /// The user change request containing the user ID and new password. - /// - /// Unauthorized if the requester is not the user or admin, BadRequest if the password is invalid or the upsert operation fails, - /// NotFound if the user does not exist, otherwise Ok with the updated user data. - /// - [Route(nameof(UpdateUserPassword))] - [HttpPost] - public async Task UpdateUserPassword([FromBody] UserChangeRequest user) - { - var userDto = await users.GetUserById(user.UserId); - - if (userDto is null) - { - return NotFound(); - } - - if (this.GetLoggedUserId() != user.UserId || !userDto.IsAdmin) - { - return Unauthorized(); - } - - if (!Passwords.IsPasswordOk(user.Password, out var message, out _)) - { - return BadRequest(message); - } - - var salt = Convert.FromBase64String(userDto.PasswordSalt); - userDto.PasswordHash = Users.HashPassword(user.Password, ref salt); - userDto.PasswordSalt = Convert.ToBase64String(salt!); - var result = await users.UpsertUser(userDto); - - return result is null ? BadRequest() : Ok(result); - } - - /// - /// Updates the user's username if the requester is authorized. - /// - /// The user change request containing the user ID and new username. - /// - /// Unauthorized if the requester is not the user or admin, NotFound if the user does not exist, - /// otherwise BadRequest if the upsert operation fails, or Ok with the updated user data. - /// - public async Task UpdateUserName([FromBody] UserChangeRequest user) - { - var userDto = await users.GetUserById(user.UserId); - - if (userDto is null) - { - return NotFound(); - } - - if (this.GetLoggedUserId() != user.UserId || !userDto.IsAdmin) - { - return Unauthorized(); - } - - if (user.Username.Length < 4) - { - return BadRequest(Passwords.UsernameMustBeAtLeast4CharactersLong); - } - - userDto.Username = user.Username; - var result = await users.UpsertUser(userDto); - - return result is null ? BadRequest() : Ok(result); - } - - /// - /// Creates a new user if the requester is admin. - /// - /// The user data to create. - /// - /// Unauthorized if the requester is not admin, BadRequest if the upsert operation fails, - /// otherwise Ok with the created user data. - /// - public async Task CreateUser([FromBody] UserChangeRequest user) - { - var loggedUser = await users.GetUserById(this.GetLoggedUserId()); - - if (await users.GetUserByName(user.Username) is not null) - { - return BadRequest(Passwords.UserAlreadyExists); - } - - if (loggedUser == null) - { - return Unauthorized(); - } - - if (loggedUser.IsAdmin) - { - if (!Passwords.IsPasswordOk(user.Password, out var message, out _)) - { - return BadRequest(message); - } - - if (user.Username.Length < 4) - { - return BadRequest(Passwords.UsernameMustBeAtLeast4CharactersLong); - } - - var userDto = new UserDto - { - Username = user.Username, - PasswordHash = string.Empty, - PasswordSalt = string.Empty, - IsAdmin = false, - }; - - var salt = Convert.FromBase64String(userDto.PasswordSalt); - userDto.PasswordHash = Users.HashPassword(user.Password, ref salt); - userDto.PasswordSalt = Convert.ToBase64String(salt!); - var result = await users.UpsertUser(userDto); - - return result is null ? BadRequest() : Ok(result); - } - - return Unauthorized(); - } /// /// An endpoint for testing unauthorized access. diff --git a/PasswordKeeper.Server/Controllers/UsersController.cs b/PasswordKeeper.Server/Controllers/UsersController.cs new file mode 100644 index 0000000..071a29e --- /dev/null +++ b/PasswordKeeper.Server/Controllers/UsersController.cs @@ -0,0 +1,161 @@ +using Microsoft.AspNetCore.Mvc; +using PasswordKeeper.BusinessLogic; +using PasswordKeeper.Classes; +using PasswordKeeper.DTO; +using PasswordKeeper.Server.Controllers.Extensions; + +namespace PasswordKeeper.Server.Controllers; + +/// +/// The users controller. +/// +[ApiController] +[Route("api/[controller]")] +public class UsersController(Users users) : ControllerBase +{ + /// + /// User change data. + /// + /// The ID of the user to change. + /// The new username for the user. + /// The new password for the user. + public record UserChangeRequest(int UserId, string Username, string Password); + + /// + /// Creates a new user if the requester is admin. + /// + /// The user data to create. + /// + /// Unauthorized if the requester is not admin, BadRequest if the upsert operation fails, + /// otherwise Ok with the created user data. + /// + [HttpPost] + [Route(nameof(CreateUser))] + public async Task CreateUser([FromBody] UserChangeRequest user) + { + var loggedUser = await users.GetUserById(this.GetLoggedUserId()); + + if (await users.GetUserByName(user.Username) is not null) + { + return BadRequest(Passwords.UserAlreadyExists); + } + + if (loggedUser == null) + { + return Unauthorized(); + } + + if (loggedUser.IsAdmin) + { + if (!Passwords.IsPasswordOk(user.Password, out var message, out _)) + { + return BadRequest(message); + } + + if (user.Username.Length < 4) + { + return BadRequest(Passwords.UsernameMustBeAtLeast4CharactersLong); + } + + var userDto = new UserDto + { + Username = user.Username, + PasswordHash = string.Empty, + PasswordSalt = string.Empty, + IsAdmin = false, + }; + + var salt = Convert.FromBase64String(userDto.PasswordSalt); + userDto.PasswordHash = Users.HashPassword(user.Password, ref salt); + userDto.PasswordSalt = Convert.ToBase64String(salt!); + var result = await users.UpsertUser(userDto); + + return result is null ? BadRequest() : Ok(result); + } + + return Unauthorized(); + } + + /// + /// Updates the user's password if the requester is authorized and the password meets the complexity requirements. + /// + /// The user change request containing the user ID and new password. + /// + /// Unauthorized if the requester is not the user or admin, BadRequest if the password is invalid or the upsert operation fails, + /// NotFound if the user does not exist, otherwise Ok with the updated user data. + /// + [Route(nameof(UpdateUserPassword))] + [HttpPost] + public async Task UpdateUserPassword([FromBody] UserChangeRequest user) + { + var userDto = await users.GetUserById(user.UserId); + + if (userDto is null) + { + return NotFound(); + } + + if (this.GetLoggedUserId() != user.UserId || !userDto.IsAdmin) + { + return Unauthorized(); + } + + if (!Passwords.IsPasswordOk(user.Password, out var message, out _)) + { + return BadRequest(message); + } + + var salt = Convert.FromBase64String(userDto.PasswordSalt); + userDto.PasswordHash = Users.HashPassword(user.Password, ref salt); + userDto.PasswordSalt = Convert.ToBase64String(salt!); + var result = await users.UpsertUser(userDto); + + return result is null ? BadRequest() : Ok(result); + } + + /// + /// Updates the user's username if the requester is authorized. + /// + /// The user change request containing the user ID and new username. + /// + /// Unauthorized if the requester is not the user or admin, NotFound if the user does not exist, + /// otherwise BadRequest if the upsert operation fails, or Ok with the updated user data. + /// + public async Task UpdateUserName([FromBody] UserChangeRequest user) + { + var userDto = await users.GetUserById(user.UserId); + + if (userDto is null) + { + return NotFound(); + } + + if (this.GetLoggedUserId() != user.UserId || !userDto.IsAdmin) + { + return Unauthorized(); + } + + if (user.Username.Length < 4) + { + return BadRequest(Passwords.UsernameMustBeAtLeast4CharactersLong); + } + + userDto.Username = user.Username; + var result = await users.UpsertUser(userDto); + + return result is null ? BadRequest() : Ok(result); + } + + /// + /// Checks a password against the password complexity requirements. + /// + /// The password to check. + /// Ok if the password meets the complexity requirements, otherwise BadRequest with an error message. + [Route(nameof(PasswordOk))] + [HttpPost] + public IActionResult PasswordOk([FromBody] string password) + { + var result = Passwords.IsPasswordOk(password, out var message, out _); + return result ? Ok() : BadRequest(message); + } +} \ No newline at end of file diff --git a/PasswordKeeper.Tests/ControllerTests.cs b/PasswordKeeper.Tests/ControllerTests.cs index 87eee0c..78b9fe7 100644 --- a/PasswordKeeper.Tests/ControllerTests.cs +++ b/PasswordKeeper.Tests/ControllerTests.cs @@ -60,13 +60,14 @@ public async Task ControllerCreateUserTest() var dbContextFactory = Helpers.GetMockDbContextFactory(nameof(ControllerTests)); var dataAccess = new PasswordKeeper.DataAccess.Users(dbContextFactory, Helpers.CreateMapper()); var businessLogic = new PasswordKeeper.BusinessLogic.Users(dataAccess); - var controller = new AuthenticationController(businessLogic); + var authenticationController = new AuthenticationController(businessLogic); + var usersController = new UsersController(businessLogic); var loginData = new AuthenticationController.UserLogin("firsUserIsAdmin", "Pa1sword%"); - await controller.Login(loginData); + await authenticationController.Login(loginData); - var user = new AuthenticationController.UserChangeRequest(0, "normalUser", "pAssw0rd_"); + var user = new UsersController.UserChangeRequest(0, "normalUser", "pAssw0rd_"); - var createdUser = await controller.CreateUser(user); + var createdUser = await usersController.CreateUser(user); Assert.That(createdUser, Is.TypeOf()); var userDto = ((OkObjectResult)createdUser).Value as UserDto; diff --git a/PasswordKeeper.Tests/Helpers.cs b/PasswordKeeper.Tests/Helpers.cs index e9e6c85..d3d531a 100644 --- a/PasswordKeeper.Tests/Helpers.cs +++ b/PasswordKeeper.Tests/Helpers.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using AutoMapper; using Microsoft.EntityFrameworkCore; +using PasswordKeeper.Classes; using PasswordKeeper.DAO; using PasswordKeeper.DataAccess; @@ -19,7 +20,7 @@ public static class Helpers public static Entities GetMemoryContext(string testClassName) { var options = new DbContextOptionsBuilder() - .UseSqlite($"Data Source=./{testClassName}.db;Pooling=False;") + .UseSqlite(DatabaseUtilities.GetSQLiteConnectionString(testClassName)) .Options; return new Entities(options); diff --git a/PasswordKeeper.Tests/MockDbContextFactory.cs b/PasswordKeeper.Tests/MockDbContextFactory.cs index 6729fe4..a17e828 100644 --- a/PasswordKeeper.Tests/MockDbContextFactory.cs +++ b/PasswordKeeper.Tests/MockDbContextFactory.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using PasswordKeeper.Classes; using PasswordKeeper.DAO; namespace PasswordKeeper.Tests; @@ -19,7 +20,7 @@ public class MockDbContextFactory(string testClassName) : IDisposableContextFact public Entities CreateDbContext() { var options = new DbContextOptionsBuilder() - .UseSqlite($"Data Source=./{testClassName}.db;Pooling=False;") + .UseSqlite(DatabaseUtilities.GetSQLiteConnectionString(testClassName)) .Options; context = new Entities(options); From 4e7f8bf7bdb5dc0a5de6f7af515ac0f54acc2b35 Mon Sep 17 00:00:00 2001 From: VPKSoft Date: Sat, 22 Mar 2025 17:54:16 +0200 Subject: [PATCH 2/6] Remove unused code. --- PasswordKeeper.Tests/Helpers.cs | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/PasswordKeeper.Tests/Helpers.cs b/PasswordKeeper.Tests/Helpers.cs index d3d531a..0827126 100644 --- a/PasswordKeeper.Tests/Helpers.cs +++ b/PasswordKeeper.Tests/Helpers.cs @@ -1,7 +1,5 @@ using System.Security.Cryptography; using AutoMapper; -using Microsoft.EntityFrameworkCore; -using PasswordKeeper.Classes; using PasswordKeeper.DAO; using PasswordKeeper.DataAccess; @@ -12,20 +10,6 @@ namespace PasswordKeeper.Tests; /// public static class Helpers { - /// - /// Creates a new SQLite database context. - /// - /// The name of the test class. - /// The database context. - public static Entities GetMemoryContext(string testClassName) - { - var options = new DbContextOptionsBuilder() - .UseSqlite(DatabaseUtilities.GetSQLiteConnectionString(testClassName)) - .Options; - - return new Entities(options); - } - /// /// Creates a new SQLite database context factory. /// From 721046a914aab4d9c8e1cec68090ff5d3fe96d21 Mon Sep 17 00:00:00 2001 From: VPKSoft Date: Sat, 22 Mar 2025 18:10:13 +0200 Subject: [PATCH 3/6] Move project properties to build properties. --- Directory.Build.props | 7 +++++++ .../PasswordKeeper.BusinessLogic.csproj | 8 +------- PasswordKeeper.Classes/PasswordKeeper.Classes.csproj | 6 ------ PasswordKeeper.DAO/PasswordKeeper.DAO.csproj | 8 +------- PasswordKeeper.DAO/User.cs | 4 ++++ PasswordKeeper.DTO/PasswordKeeper.DTO.csproj | 6 ------ PasswordKeeper.DTO/UserDto.cs | 3 +++ .../PasswordKeeper.DataAccess.csproj | 6 ------ .../PasswordKeeper.DatabaseMigrations.csproj | 3 --- PasswordKeeper.Interfaces/IUser.cs | 5 +++++ .../PasswordKeeper.Interfaces.csproj | 6 ------ PasswordKeeper.Server/PasswordKeeper.Server.csproj | 3 --- PasswordKeeper.Tests/PasswordKeeper.Tests.csproj | 4 ---- 13 files changed, 21 insertions(+), 48 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 8b8f2ac..b6340c5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,9 +3,16 @@ true none.ignore default + net9.0 + enable + enable + + + CS8618 + \ No newline at end of file diff --git a/PasswordKeeper.BusinessLogic/PasswordKeeper.BusinessLogic.csproj b/PasswordKeeper.BusinessLogic/PasswordKeeper.BusinessLogic.csproj index fc9640d..0b3a384 100644 --- a/PasswordKeeper.BusinessLogic/PasswordKeeper.BusinessLogic.csproj +++ b/PasswordKeeper.BusinessLogic/PasswordKeeper.BusinessLogic.csproj @@ -1,11 +1,5 @@  - - - net9.0 - enable - enable - - + diff --git a/PasswordKeeper.Classes/PasswordKeeper.Classes.csproj b/PasswordKeeper.Classes/PasswordKeeper.Classes.csproj index 6e17900..cb81b7a 100644 --- a/PasswordKeeper.Classes/PasswordKeeper.Classes.csproj +++ b/PasswordKeeper.Classes/PasswordKeeper.Classes.csproj @@ -1,11 +1,5 @@  - - net9.0 - enable - enable - - diff --git a/PasswordKeeper.DAO/PasswordKeeper.DAO.csproj b/PasswordKeeper.DAO/PasswordKeeper.DAO.csproj index c90f988..125f219 100644 --- a/PasswordKeeper.DAO/PasswordKeeper.DAO.csproj +++ b/PasswordKeeper.DAO/PasswordKeeper.DAO.csproj @@ -1,11 +1,5 @@  - - - net9.0 - enable - enable - - + diff --git a/PasswordKeeper.DAO/User.cs b/PasswordKeeper.DAO/User.cs index c736bfe..3ee5790 100644 --- a/PasswordKeeper.DAO/User.cs +++ b/PasswordKeeper.DAO/User.cs @@ -17,6 +17,10 @@ public class User : IUser [MaxLength(255)] public string Username { get; set; } = string.Empty; + /// + [MaxLength(512)] + public string UserFullName { get; set; } = string.Empty; + /// [MaxLength(1000)] public string PasswordHash { get; set; } = string.Empty; diff --git a/PasswordKeeper.DTO/PasswordKeeper.DTO.csproj b/PasswordKeeper.DTO/PasswordKeeper.DTO.csproj index b229e05..9fa87b2 100644 --- a/PasswordKeeper.DTO/PasswordKeeper.DTO.csproj +++ b/PasswordKeeper.DTO/PasswordKeeper.DTO.csproj @@ -1,11 +1,5 @@  - - net9.0 - enable - enable - - diff --git a/PasswordKeeper.DTO/UserDto.cs b/PasswordKeeper.DTO/UserDto.cs index 2610ea6..f96bf94 100644 --- a/PasswordKeeper.DTO/UserDto.cs +++ b/PasswordKeeper.DTO/UserDto.cs @@ -14,6 +14,9 @@ public class UserDto : IUser /// public string Username { get; set; } = string.Empty; + /// + public string UserFullName { get; set; } = string.Empty; + /// [JsonIgnore] public string PasswordHash { get; set; } = string.Empty; diff --git a/PasswordKeeper.DataAccess/PasswordKeeper.DataAccess.csproj b/PasswordKeeper.DataAccess/PasswordKeeper.DataAccess.csproj index 1eb2392..3969f7f 100644 --- a/PasswordKeeper.DataAccess/PasswordKeeper.DataAccess.csproj +++ b/PasswordKeeper.DataAccess/PasswordKeeper.DataAccess.csproj @@ -1,11 +1,5 @@  - - net9.0 - enable - enable - - diff --git a/PasswordKeeper.DatabaseMigrations/PasswordKeeper.DatabaseMigrations.csproj b/PasswordKeeper.DatabaseMigrations/PasswordKeeper.DatabaseMigrations.csproj index fc78c10..422a2e4 100644 --- a/PasswordKeeper.DatabaseMigrations/PasswordKeeper.DatabaseMigrations.csproj +++ b/PasswordKeeper.DatabaseMigrations/PasswordKeeper.DatabaseMigrations.csproj @@ -2,9 +2,6 @@ Exe - net9.0 - enable - enable diff --git a/PasswordKeeper.Interfaces/IUser.cs b/PasswordKeeper.Interfaces/IUser.cs index f5e7df7..d703a8e 100644 --- a/PasswordKeeper.Interfaces/IUser.cs +++ b/PasswordKeeper.Interfaces/IUser.cs @@ -10,6 +10,11 @@ public interface IUser : IHasId /// string Username { get; set; } + /// + /// The full name of the user. + /// + string UserFullName { get; set; } + /// /// The password of the user. /// diff --git a/PasswordKeeper.Interfaces/PasswordKeeper.Interfaces.csproj b/PasswordKeeper.Interfaces/PasswordKeeper.Interfaces.csproj index 17b910f..c632161 100644 --- a/PasswordKeeper.Interfaces/PasswordKeeper.Interfaces.csproj +++ b/PasswordKeeper.Interfaces/PasswordKeeper.Interfaces.csproj @@ -1,9 +1,3 @@  - - net9.0 - enable - enable - - diff --git a/PasswordKeeper.Server/PasswordKeeper.Server.csproj b/PasswordKeeper.Server/PasswordKeeper.Server.csproj index 88c945b..9009082 100644 --- a/PasswordKeeper.Server/PasswordKeeper.Server.csproj +++ b/PasswordKeeper.Server/PasswordKeeper.Server.csproj @@ -1,9 +1,6 @@ - net9.0 - enable - enable Linux diff --git a/PasswordKeeper.Tests/PasswordKeeper.Tests.csproj b/PasswordKeeper.Tests/PasswordKeeper.Tests.csproj index 80dc29f..1093705 100644 --- a/PasswordKeeper.Tests/PasswordKeeper.Tests.csproj +++ b/PasswordKeeper.Tests/PasswordKeeper.Tests.csproj @@ -1,10 +1,6 @@ - net9.0 - enable - enable - false true From 1ca81f8e82466ab12702824ac6e2dfa0fcf1c0fc Mon Sep 17 00:00:00 2001 From: VPKSoft Date: Sat, 22 Mar 2025 18:18:42 +0200 Subject: [PATCH 4/6] Add user full name to the user handling. --- PasswordKeeper.BusinessLogic/Users.cs | 1 + .../Migrations/001_InitialMigration.cs | 1 + .../Controllers/AuthenticationController.cs | 3 --- PasswordKeeper.Server/Controllers/UsersController.cs | 8 +++++--- PasswordKeeper.Tests/ControllerTests.cs | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/PasswordKeeper.BusinessLogic/Users.cs b/PasswordKeeper.BusinessLogic/Users.cs index 9b63862..8802e0b 100644 --- a/PasswordKeeper.BusinessLogic/Users.cs +++ b/PasswordKeeper.BusinessLogic/Users.cs @@ -123,6 +123,7 @@ public async Task Login(string username, string password, byte[] jw PasswordHash = HashPassword(password, ref salt), PasswordSalt = Convert.ToBase64String(salt!), IsAdmin = true, + UserFullName = "Administrator", }; userDto = await users.UpsertUser(userDto); diff --git a/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs b/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs index f578f47..3bdcf92 100644 --- a/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs +++ b/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs @@ -24,6 +24,7 @@ public override void Up() this.Create.Table(nameof(User)).InSchemaIf(Program.DatabaseName, !isSqlite) .WithColumn(nameof(User.Id)).AsInt64().NotNullable().PrimaryKey().Identity() .WithColumn(nameof(User.Username)).AsString(255).NotNullable().Unique() + .WithColumn(nameof(User.UserFullName)).AsString(512).NotNullable() .WithColumn(nameof(User.PasswordHash)).AsString(1000).NotNullable() .WithColumn(nameof(User.PasswordSalt)).AsString(1000).NotNullable() .WithColumn(nameof(User.IsAdmin)).AsBoolean().NotNullable().WithDefaultValue(false); diff --git a/PasswordKeeper.Server/Controllers/AuthenticationController.cs b/PasswordKeeper.Server/Controllers/AuthenticationController.cs index c7da460..c38575a 100644 --- a/PasswordKeeper.Server/Controllers/AuthenticationController.cs +++ b/PasswordKeeper.Server/Controllers/AuthenticationController.cs @@ -1,9 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using PasswordKeeper.BusinessLogic; -using PasswordKeeper.Classes; -using PasswordKeeper.DTO; -using PasswordKeeper.Server.Controllers.Extensions; namespace PasswordKeeper.Server.Controllers; diff --git a/PasswordKeeper.Server/Controllers/UsersController.cs b/PasswordKeeper.Server/Controllers/UsersController.cs index 071a29e..85e8868 100644 --- a/PasswordKeeper.Server/Controllers/UsersController.cs +++ b/PasswordKeeper.Server/Controllers/UsersController.cs @@ -19,7 +19,7 @@ public class UsersController(Users users) : ControllerBase /// The ID of the user to change. /// The new username for the user. /// The new password for the user. - public record UserChangeRequest(int UserId, string Username, string Password); + public record UserChangeRequest(int UserId, string Username, string Password, string UserFullName); /// /// Creates a new user if the requester is admin. @@ -63,6 +63,7 @@ public async Task CreateUser([FromBody] UserChangeRequest user) PasswordHash = string.Empty, PasswordSalt = string.Empty, IsAdmin = false, + UserFullName = user.UserFullName, }; var salt = Convert.FromBase64String(userDto.PasswordSalt); @@ -114,9 +115,9 @@ public async Task UpdateUserPassword([FromBody] UserChangeRequest } /// - /// Updates the user's username if the requester is authorized. + /// Updates the user's username and full name if the requester is authorized. /// - /// The user change request containing the user ID and new username. + /// The user change request containing the user ID, new username and new full name. /// /// Unauthorized if the requester is not the user or admin, NotFound if the user does not exist, /// otherwise BadRequest if the upsert operation fails, or Ok with the updated user data. @@ -141,6 +142,7 @@ public async Task UpdateUserName([FromBody] UserChangeRequest use } userDto.Username = user.Username; + userDto.UserFullName = user.UserFullName; var result = await users.UpsertUser(userDto); return result is null ? BadRequest() : Ok(result); diff --git a/PasswordKeeper.Tests/ControllerTests.cs b/PasswordKeeper.Tests/ControllerTests.cs index 78b9fe7..4dc3d86 100644 --- a/PasswordKeeper.Tests/ControllerTests.cs +++ b/PasswordKeeper.Tests/ControllerTests.cs @@ -65,7 +65,7 @@ public async Task ControllerCreateUserTest() var loginData = new AuthenticationController.UserLogin("firsUserIsAdmin", "Pa1sword%"); await authenticationController.Login(loginData); - var user = new UsersController.UserChangeRequest(0, "normalUser", "pAssw0rd_"); + var user = new UsersController.UserChangeRequest(0, "normalUser", "pAssw0rd_", "Normal User"); var createdUser = await usersController.CreateUser(user); From 12b6fda263b0d696daec9424d0c9ebddcf5238b9 Mon Sep 17 00:00:00 2001 From: VPKSoft Date: Sun, 23 Mar 2025 12:45:49 +0200 Subject: [PATCH 5/6] Add authentication roles and get all users route. --- PasswordKeeper.BusinessLogic/Users.cs | 14 +++++- PasswordKeeper.Classes/Passwords.cs | 8 ++- PasswordKeeper.DataAccess/Users.cs | 20 ++++++++ .../Controllers/AuthenticationController.cs | 11 +++++ .../Controllers/UsersController.cs | 49 ++++++++++++++++++- PasswordKeeper.Server/Program.cs | 3 ++ 6 files changed, 100 insertions(+), 5 deletions(-) diff --git a/PasswordKeeper.BusinessLogic/Users.cs b/PasswordKeeper.BusinessLogic/Users.cs index 8802e0b..80dc979 100644 --- a/PasswordKeeper.BusinessLogic/Users.cs +++ b/PasswordKeeper.BusinessLogic/Users.cs @@ -41,6 +41,12 @@ public async Task> GetAllUsers() return await users.GetAllUsers(); } + /// + public async Task DeleteUser(long id) + { + await users.DeleteUser(id); + } + private const int IterationCount = 1000000; /// @@ -133,9 +139,13 @@ public async Task Login(string username, string password, byte[] jw return new LoginResult(false, Passwords.CreateMessageString(LoginRejectReason.FailedToCreateAdminUser), false, LoginRejectReason.FailedToCreateAdminUser); } - var token = Passwords.GenerateJwtToken(username, userDto.Id, jwtKey, pseudoDomain); + var token = Passwords.GenerateJwtToken(username, userDto.Id, jwtKey, pseudoDomain, userDto.IsAdmin); return new LoginResult(true, token, false, LoginRejectReason.None); } + else + { + userDto = await users.GetUserByName(username); + } // An existing user, verify the password if (userDto is not null) @@ -143,7 +153,7 @@ public async Task Login(string username, string password, byte[] jw if (Users.VerifyPassword(password, userDto.PasswordHash, Convert.FromBase64String(userDto.PasswordSalt))) { - var token = Passwords.GenerateJwtToken(username, userDto.Id, jwtKey, pseudoDomain); + var token = Passwords.GenerateJwtToken(username, userDto.Id, jwtKey, pseudoDomain, userDto.IsAdmin); return new LoginResult(true, token, false, LoginRejectReason.None); } } diff --git a/PasswordKeeper.Classes/Passwords.cs b/PasswordKeeper.Classes/Passwords.cs index d1992cb..f27cbf9 100644 --- a/PasswordKeeper.Classes/Passwords.cs +++ b/PasswordKeeper.Classes/Passwords.cs @@ -18,14 +18,16 @@ public static class Passwords /// The user ID to generate the JWT token for. /// The JWT key to use for signing the token. /// The pseudo domain to use for the token issuer and audience. + /// Whether the user is an admin or not. /// The JWT token. - public static string GenerateJwtToken(string username, long userId, byte[] jwtKey, string pseudoDomain) + public static string GenerateJwtToken(string username, long userId, byte[] jwtKey, string pseudoDomain, bool isAdmin) { var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, username), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.NameId, userId.ToString()), + new Claim(ClaimTypes.Role, isAdmin ? "Admin" : "User"), }; var key = new SymmetricSecurityKey(jwtKey); @@ -35,7 +37,11 @@ public static string GenerateJwtToken(string username, long userId, byte[] jwtKe issuer: pseudoDomain, audience: pseudoDomain, claims: claims, + #if DEBUG + expires: DateTime.Now.AddYears(1), + #else expires: DateTime.Now.AddMinutes(30), + #endif signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); diff --git a/PasswordKeeper.DataAccess/Users.cs b/PasswordKeeper.DataAccess/Users.cs index 2942264..5e10861 100644 --- a/PasswordKeeper.DataAccess/Users.cs +++ b/PasswordKeeper.DataAccess/Users.cs @@ -84,6 +84,26 @@ public async Task UsersExist(bool? admin = null) return mapper.Map(user); } + /// + /// Deletes the user with the given ID. + /// + /// The ID of the user to delete. + /// A task representing the asynchronous operation. + public async Task DeleteUser(long id) + { + await using var context = await dbContextFactory.CreateDbContextAsync(); + + var user = await context.Users.FirstOrDefaultAsync(user => user.Id == id); + + if (user != null) + { + context.Users.Remove(user); + await context.SaveChangesAsync(); + } + + // TODO:Delete user data + } + /// /// Gets all users. /// diff --git a/PasswordKeeper.Server/Controllers/AuthenticationController.cs b/PasswordKeeper.Server/Controllers/AuthenticationController.cs index c38575a..f387fd4 100644 --- a/PasswordKeeper.Server/Controllers/AuthenticationController.cs +++ b/PasswordKeeper.Server/Controllers/AuthenticationController.cs @@ -51,4 +51,15 @@ public IActionResult TestUnauthorized() { return Ok(); } + + /// + /// An endpoint for testing admin access. + /// + /// An Ok result if the request is authorized. + [Route(nameof(TestAdminAuthorized))] + [Authorize(Roles = "Admin")] + public IActionResult TestAdminAuthorized() + { + return Ok(); + } } \ No newline at end of file diff --git a/PasswordKeeper.Server/Controllers/UsersController.cs b/PasswordKeeper.Server/Controllers/UsersController.cs index 85e8868..a199ca5 100644 --- a/PasswordKeeper.Server/Controllers/UsersController.cs +++ b/PasswordKeeper.Server/Controllers/UsersController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using PasswordKeeper.BusinessLogic; using PasswordKeeper.Classes; using PasswordKeeper.DTO; @@ -31,6 +32,7 @@ public record UserChangeRequest(int UserId, string Username, string Password, st /// [HttpPost] [Route(nameof(CreateUser))] + [Authorize(Roles = "Admin")] public async Task CreateUser([FromBody] UserChangeRequest user) { var loggedUser = await users.GetUserById(this.GetLoggedUserId()); @@ -66,7 +68,7 @@ public async Task CreateUser([FromBody] UserChangeRequest user) UserFullName = user.UserFullName, }; - var salt = Convert.FromBase64String(userDto.PasswordSalt); + var salt = string.IsNullOrEmpty(userDto.PasswordSalt) ? null : Convert.FromBase64String(userDto.PasswordSalt); userDto.PasswordHash = Users.HashPassword(user.Password, ref salt); userDto.PasswordSalt = Convert.ToBase64String(salt!); var result = await users.UpsertUser(userDto); @@ -148,6 +150,49 @@ public async Task UpdateUserName([FromBody] UserChangeRequest use return result is null ? BadRequest() : Ok(result); } + /// + /// Deletes the user with the given ID if the requester is admin. + /// + /// The ID of the user to delete. + /// Unauthorized if the requester is not admin, NotFound if the user does not exist, otherwise Ok. + [Route(nameof(DeleteUser))] + [HttpDelete] + public async Task DeleteUser(int userId) + { + var userDto = await users.GetUserById(userId); + + if (userDto is null) + { + return NotFound(); + } + + var loggedUserId = this.GetLoggedUserId(); + var isLoggedUserAdmin = await users.GetUserById(loggedUserId) is { IsAdmin: true }; + + // Only admins can delete users and admins cannot delete themselves + if (!isLoggedUserAdmin || loggedUserId == userId) + { + return Unauthorized(); + } + + await users.DeleteUser(userId); + + return Ok(); + } + + /// + /// Gets all users. + /// + /// A collection of all users. + [Route(nameof(GetAllUsers))] + [HttpGet] + [Authorize(Roles = "Admin")] + public async Task> GetAllUsers() + { + + return await users.GetAllUsers(); + } + /// /// Checks a password against the password complexity requirements. /// diff --git a/PasswordKeeper.Server/Program.cs b/PasswordKeeper.Server/Program.cs index e90ea18..6ac0750 100644 --- a/PasswordKeeper.Server/Program.cs +++ b/PasswordKeeper.Server/Program.cs @@ -1,4 +1,6 @@ +using System.Security.Claims; using System.Security.Cryptography; +using System.Text.Json; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; @@ -42,6 +44,7 @@ public static void Main(string[] args) ValidIssuer = Program.PseudoDomain, ValidAudience = Program.PseudoDomain, IssuerSigningKey = new SymmetricSecurityKey(GetJwtKey()), + RoleClaimType = ClaimTypes.Role, }; }); From 557c4c3b22248e8e774835f8ff467000344c4883 Mon Sep 17 00:00:00 2001 From: VPKSoft Date: Sun, 23 Mar 2025 13:57:27 +0200 Subject: [PATCH 6/6] Add alive controller for anonymous test. --- .../Controllers/AliveController.cs | 22 +++++++++++++++++++ .../Controllers/UsersController.cs | 3 ++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 PasswordKeeper.Server/Controllers/AliveController.cs diff --git a/PasswordKeeper.Server/Controllers/AliveController.cs b/PasswordKeeper.Server/Controllers/AliveController.cs new file mode 100644 index 0000000..a0a9c11 --- /dev/null +++ b/PasswordKeeper.Server/Controllers/AliveController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace PasswordKeeper.Server.Controllers; + +/// +/// The alive controller. +/// +public class AliveController : ControllerBase +{ + /// + /// Gets the current date and time.S + /// + /// The current date and time. + [Route("")] + [AllowAnonymous] + [HttpGet] + public DateTimeOffset Get() + { + return DateTimeOffset.UtcNow; + } +} \ No newline at end of file diff --git a/PasswordKeeper.Server/Controllers/UsersController.cs b/PasswordKeeper.Server/Controllers/UsersController.cs index a199ca5..40604d3 100644 --- a/PasswordKeeper.Server/Controllers/UsersController.cs +++ b/PasswordKeeper.Server/Controllers/UsersController.cs @@ -19,7 +19,8 @@ public class UsersController(Users users) : ControllerBase /// /// The ID of the user to change. /// The new username for the user. - /// The new password for the user. + /// The new password for the user. + /// The new full name for the user. public record UserChangeRequest(int UserId, string Username, string Password, string UserFullName); ///