diff --git a/PasswordKeeper.BusinessLogic/Users.cs b/PasswordKeeper.BusinessLogic/Users.cs index f66b8d9..b4b6399 100644 --- a/PasswordKeeper.BusinessLogic/Users.cs +++ b/PasswordKeeper.BusinessLogic/Users.cs @@ -24,7 +24,7 @@ public class Users(PasswordKeeper.DataAccess.Users users) } /// - public async Task GetUserById(int id) + public async Task GetUserById(long id) { return await users.GetUserById(id); } @@ -113,7 +113,7 @@ public async Task Login(string username, string password, byte[] jw byte []? salt = null; userDto = new UserDto { - UserName = username, + Username = username, PasswordHash = HashPassword(password, ref salt), PasswordSalt = Convert.ToBase64String(salt!), IsAdmin = true, diff --git a/PasswordKeeper.Classes/Passwords.cs b/PasswordKeeper.Classes/Passwords.cs index 13ddfe6..d1992cb 100644 --- a/PasswordKeeper.Classes/Passwords.cs +++ b/PasswordKeeper.Classes/Passwords.cs @@ -154,6 +154,11 @@ public static string CreateMessageString(LoginRejectReason reason) /// An error message indicating that the username or password is invalid. /// public const string InvalidUsernameOrPassword = "Invalid username or password."; + + /// + /// An error message indicating that the user already exists. + /// + public const string UserAlreadyExists = "User already exists."; /// /// A read-only dictionary mapping login reject reasons to error messages. @@ -179,6 +184,7 @@ public static string CreateMessageString(LoginRejectReason reason) FailedToCreateAdminUser), new KeyValuePair(LoginRejectReason.InvalidUsernameOrPassword, InvalidUsernameOrPassword), - new KeyValuePair(LoginRejectReason.NotFound, string.Empty) + new KeyValuePair(LoginRejectReason.NotFound, string.Empty), + new KeyValuePair(LoginRejectReason.UserAlreadyExists, UserAlreadyExists), ]))); } \ No newline at end of file diff --git a/PasswordKeeper.DAO/User.cs b/PasswordKeeper.DAO/User.cs index 4e0653a..c736bfe 100644 --- a/PasswordKeeper.DAO/User.cs +++ b/PasswordKeeper.DAO/User.cs @@ -13,9 +13,9 @@ public class User : IUser /// public long Id { get; set; } - /// + /// [MaxLength(255)] - public string UserName { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; /// [MaxLength(1000)] diff --git a/PasswordKeeper.DTO/UserDto.cs b/PasswordKeeper.DTO/UserDto.cs index 1d2fbc5..2610ea6 100644 --- a/PasswordKeeper.DTO/UserDto.cs +++ b/PasswordKeeper.DTO/UserDto.cs @@ -11,8 +11,8 @@ public class UserDto : IUser /// public long Id { get; set; } - /// - public string UserName { get; set; } = string.Empty; + /// + public string Username { get; set; } = string.Empty; /// [JsonIgnore] diff --git a/PasswordKeeper.DataAccess/Users.cs b/PasswordKeeper.DataAccess/Users.cs index 5ce4f7b..070b324 100644 --- a/PasswordKeeper.DataAccess/Users.cs +++ b/PasswordKeeper.DataAccess/Users.cs @@ -21,7 +21,7 @@ public class Users(IDbContextFactory dbContextFactory, IMapper mapper) { await using var context = await dbContextFactory.CreateDbContextAsync(); - var user = await context.Users.FirstOrDefaultAsync(user => user.UserName == name); + var user = await context.Users.FirstOrDefaultAsync(user => user.Username == name); return mapper.Map(user); } @@ -31,7 +31,7 @@ public class Users(IDbContextFactory dbContextFactory, IMapper mapper) /// /// The user ID to search for. /// The user with the given ID, or null if it doesn't exist. - public async Task GetUserById(int id) + public async Task GetUserById(long id) { await using var context = await dbContextFactory.CreateDbContextAsync(); @@ -67,7 +67,7 @@ public async Task UsersExist(bool? admin = null) { await using var context = await dbContextFactory.CreateDbContextAsync(); - var user = await context.Users.FirstOrDefaultAsync(user => user.UserName == userDto.UserName); + var user = await context.Users.FirstOrDefaultAsync(user => user.Username == userDto.Username); if (user == null) { diff --git a/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs b/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs index 8fd7bc7..f578f47 100644 --- a/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs +++ b/PasswordKeeper.DatabaseMigrations/Migrations/001_InitialMigration.cs @@ -23,7 +23,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.Username)).AsString(255).NotNullable().Unique() .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.DatabaseMigrations/Program.cs b/PasswordKeeper.DatabaseMigrations/Program.cs index 8adbfc1..5e8d164 100644 --- a/PasswordKeeper.DatabaseMigrations/Program.cs +++ b/PasswordKeeper.DatabaseMigrations/Program.cs @@ -50,7 +50,8 @@ public void OnExecute() string connectionString; if (TestDbName != null) { - connectionString = $"Data Source=./{TestDbName}.db"; + // NOTE: Pooling=False is required for SQLite for the database file to be released after migrations! + connectionString = $"Data Source=./{TestDbName}.db;Pooling=False;"; IsTestDb = true; } else diff --git a/PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs b/PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs index 352c82f..98bc19f 100644 --- a/PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs +++ b/PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs @@ -58,5 +58,10 @@ public enum LoginRejectReason /// /// Data related to the user was not found. /// - NotFound, + NotFound = 10, + + /// + /// The user already exists. + /// + UserAlreadyExists = 11, } \ No newline at end of file diff --git a/PasswordKeeper.Interfaces/IUser.cs b/PasswordKeeper.Interfaces/IUser.cs index 7c01950..f5e7df7 100644 --- a/PasswordKeeper.Interfaces/IUser.cs +++ b/PasswordKeeper.Interfaces/IUser.cs @@ -8,7 +8,7 @@ public interface IUser : IHasId /// /// The name of the user. /// - string UserName { get; set; } + string Username { get; set; } /// /// The password of the user. diff --git a/PasswordKeeper.Server/Controllers/AuthenticationController.cs b/PasswordKeeper.Server/Controllers/AuthenticationController.cs index 2fedc9a..d80c528 100644 --- a/PasswordKeeper.Server/Controllers/AuthenticationController.cs +++ b/PasswordKeeper.Server/Controllers/AuthenticationController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using PasswordKeeper.BusinessLogic; using PasswordKeeper.Classes; +using PasswordKeeper.DTO; using PasswordKeeper.Server.Controllers.Extensions; namespace PasswordKeeper.Server.Controllers; @@ -130,12 +131,65 @@ public async Task UpdateUserName([FromBody] UserChangeRequest use return BadRequest(Passwords.UsernameMustBeAtLeast4CharactersLong); } - userDto.UserName = user.Username; + 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/Extensions/ControllerExtensions.cs b/PasswordKeeper.Server/Controllers/Extensions/ControllerExtensions.cs index 0a297ef..0ff9d34 100644 --- a/PasswordKeeper.Server/Controllers/Extensions/ControllerExtensions.cs +++ b/PasswordKeeper.Server/Controllers/Extensions/ControllerExtensions.cs @@ -14,15 +14,25 @@ public static class ControllerExtensions /// The logged-in user's ID, or -1 if the claim containing the user ID is not found. public static long GetLoggedUserId(this ControllerBase controllerBase) { - var claim = controllerBase.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier && long.TryParse(c.Value, out _)); - + return GetLoggedUserIdFunc(controllerBase); + } + + /// + /// A delegate to get the logged-in user's ID. This is called by the method. + /// + /// A delegate is used to allow unit testing. + public static Func GetLoggedUserIdFunc = controllerBase => + { + var claim = controllerBase.User.Claims.FirstOrDefault(c => + c.Type == ClaimTypes.NameIdentifier && long.TryParse(c.Value, out _)); + if (long.TryParse(claim?.Value, out var result)) { return result; } - + return -1; - } + }; /// /// Gets the logged-in user's name. @@ -30,6 +40,15 @@ public static long GetLoggedUserId(this ControllerBase controllerBase) /// The logged-in user's name, or an empty string if the user is not logged in. public static string GetLoggedUserName(this ControllerBase controllerBase) { - return controllerBase.User.Identity?.Name ?? string.Empty; + return GetLoggedUserNameFunc(controllerBase); } + + /// + /// A delegate to get the logged-in user's name. This is called by the method. + /// + /// A delegate is used to allow unit testing. + public static Func GetLoggedUserNameFunc = controllerBase => + { + return controllerBase.User.Identity?.Name ?? string.Empty; + }; } \ No newline at end of file diff --git a/PasswordKeeper.Tests/ControllerTests.cs b/PasswordKeeper.Tests/ControllerTests.cs index f961a15..87eee0c 100644 --- a/PasswordKeeper.Tests/ControllerTests.cs +++ b/PasswordKeeper.Tests/ControllerTests.cs @@ -1,8 +1,10 @@ using Microsoft.AspNetCore.Mvc; using PasswordKeeper.Classes; using PasswordKeeper.DatabaseMigrations; +using PasswordKeeper.DTO; using PasswordKeeper.Interfaces.Enumerations; using PasswordKeeper.Server.Controllers; +using PasswordKeeper.Server.Controllers.Extensions; namespace PasswordKeeper.Tests; @@ -20,15 +22,18 @@ public void Setup() Helpers.DeleteDatabase(nameof(ControllerTests)); Program.Main([$"-t {nameof(ControllerTests)}",]); Server.Program.GetJwtKey = Helpers.GetJwtKey; + ControllerExtensions.GetLoggedUserIdFunc = @base => 1; + ControllerExtensions.GetLoggedUserNameFunc = @base => "firsUserIsAdmin"; } /// - /// Tests the authentication controller. + /// Tests the AuthenticationController.Login action. /// [Test] - public async Task AuthenticationControllerTest() + public async Task AuthenticationControllerLoginTest() { - var dataAccess = new PasswordKeeper.DataAccess.Users(Helpers.GetMockDbContextFactory(nameof(ControllerTests)), Helpers.CreateMapper()); + 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 loginData = new AuthenticationController.UserLogin("firsUserIsAdmin", "password"); @@ -43,5 +48,30 @@ public async Task AuthenticationControllerTest() loginData = new AuthenticationController.UserLogin("firsUserIsAdmin", "Pa1sword%"); result = await controller.Login(loginData); Assert.That(result, Is.TypeOf()); + await dbContextFactory.DisposeAsync(); + } + + /// + /// Tests the AuthenticationController.CreateUser action. + /// + [Test] + 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 loginData = new AuthenticationController.UserLogin("firsUserIsAdmin", "Pa1sword%"); + await controller.Login(loginData); + + var user = new AuthenticationController.UserChangeRequest(0, "normalUser", "pAssw0rd_"); + + var createdUser = await controller.CreateUser(user); + + Assert.That(createdUser, Is.TypeOf()); + var userDto = ((OkObjectResult)createdUser).Value as UserDto; + Assert.That(userDto, Is.TypeOf()); + Assert.That(userDto.Username, Is.EqualTo("normalUser")); + await dbContextFactory.DisposeAsync(); } } \ No newline at end of file diff --git a/PasswordKeeper.Tests/Helpers.cs b/PasswordKeeper.Tests/Helpers.cs index 2b88cb2..e9e6c85 100644 --- a/PasswordKeeper.Tests/Helpers.cs +++ b/PasswordKeeper.Tests/Helpers.cs @@ -19,7 +19,7 @@ public static class Helpers public static Entities GetMemoryContext(string testClassName) { var options = new DbContextOptionsBuilder() - .UseSqlite($"Data Source=./{testClassName}.db") + .UseSqlite($"Data Source=./{testClassName}.db;Pooling=False;") .Options; return new Entities(options); @@ -30,11 +30,11 @@ public static Entities GetMemoryContext(string testClassName) /// /// The name of the test class. /// The database context. - public static IDbContextFactory GetMockDbContextFactory(string testClassName) + public static IDisposableContextFactory GetMockDbContextFactory(string testClassName) { return new MockDbContextFactory(testClassName); } - + /// /// Deletes the database file. /// @@ -47,7 +47,7 @@ public static void DeleteDatabase(string testClassName) File.Delete(dbFile); } } - + /// /// Creates an instance to a class implementing the interface. /// diff --git a/PasswordKeeper.Tests/IDisposableContextFactory.cs b/PasswordKeeper.Tests/IDisposableContextFactory.cs new file mode 100644 index 0000000..234156f --- /dev/null +++ b/PasswordKeeper.Tests/IDisposableContextFactory.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +namespace PasswordKeeper.Tests; + +/// +/// A disposable database context factory. +/// +/// The type of the database context. +/// +/// +/// +public interface IDisposableContextFactory : IDbContextFactory, IDisposable, IAsyncDisposable + where T : DbContext; \ No newline at end of file diff --git a/PasswordKeeper.Tests/MockDbContextFactory.cs b/PasswordKeeper.Tests/MockDbContextFactory.cs index 46096a5..6729fe4 100644 --- a/PasswordKeeper.Tests/MockDbContextFactory.cs +++ b/PasswordKeeper.Tests/MockDbContextFactory.cs @@ -8,8 +8,10 @@ namespace PasswordKeeper.Tests; /// /// The name of the test class. /// -public class MockDbContextFactory(string testClassName) : IDbContextFactory +public class MockDbContextFactory(string testClassName) : IDisposableContextFactory { + private Entities? context; + /// /// Creates a new SQLite database context. /// @@ -17,9 +19,28 @@ public class MockDbContextFactory(string testClassName) : IDbContextFactory() - .UseSqlite($"Data Source=./{testClassName}.db") + .UseSqlite($"Data Source=./{testClassName}.db;Pooling=False;") .Options; - return new Entities(options); + context = new Entities(options); + + return context; + } + + /// + public void Dispose() + { + context?.Dispose(); + context = null; + } + + /// + public async ValueTask DisposeAsync() + { + if (context != null) + { + await context.DisposeAsync(); + context = null; + } } } \ No newline at end of file