diff --git a/PasswordKeeper.BusinessLogic/PasswordKeeper.BusinessLogic.csproj b/PasswordKeeper.BusinessLogic/PasswordKeeper.BusinessLogic.csproj index 1942689..fc9640d 100644 --- a/PasswordKeeper.BusinessLogic/PasswordKeeper.BusinessLogic.csproj +++ b/PasswordKeeper.BusinessLogic/PasswordKeeper.BusinessLogic.csproj @@ -12,6 +12,7 @@ + diff --git a/PasswordKeeper.BusinessLogic/Users.cs b/PasswordKeeper.BusinessLogic/Users.cs index 6112efd..f66b8d9 100644 --- a/PasswordKeeper.BusinessLogic/Users.cs +++ b/PasswordKeeper.BusinessLogic/Users.cs @@ -1,6 +1,8 @@ using System.Security.Cryptography; using Microsoft.AspNetCore.Cryptography.KeyDerivation; +using PasswordKeeper.Classes; using PasswordKeeper.DTO; +using PasswordKeeper.Interfaces.Enumerations; namespace PasswordKeeper.BusinessLogic; @@ -72,4 +74,73 @@ public static bool VerifyPassword(string password, string hashedPassword, byte[] return verifyHash == hashedPassword; } + + /// + /// Login result. + /// + /// Whether the login was successful. + /// The message associated with the login result. + /// Whether the login was unauthorized. + /// The reason for the login rejection. + public record LoginResult(bool Success, string Message, bool Unauthorized, LoginRejectReason Reason); + + /// + /// Logs in the user and returns a JWT token. + /// + /// The username. + /// The password. + /// The JWT key. + /// The pseudo domain. + /// A JWT token if the login is valid, otherwise an error message. + public async Task Login(string username, string password, byte[] jwtKey, string pseudoDomain) + { + var adminExists = await users.UsersExist(true); + UserDto? userDto = null; + + if (username.Length < 4) + { + return new LoginResult(false, Passwords.CreateMessageString(LoginRejectReason.UsernameMustBeAtLeast4Characters), false, LoginRejectReason.UsernameMustBeAtLeast4Characters); + } + + if (!Passwords.IsPasswordOk(password, out _, out var reason)) + { + return new LoginResult(false, Passwords.CreateMessageString(reason), false, reason); + } + + // If no admin user exists, create one if the username is long enough and the password is valid + if (!adminExists) + { + byte []? salt = null; + userDto = new UserDto + { + UserName = username, + PasswordHash = HashPassword(password, ref salt), + PasswordSalt = Convert.ToBase64String(salt!), + IsAdmin = true, + }; + + userDto = await users.UpsertUser(userDto); + + if (userDto is null) + { + return new LoginResult(false, Passwords.CreateMessageString(LoginRejectReason.FailedToCreateAdminUser), false, LoginRejectReason.FailedToCreateAdminUser); + } + + var token = Passwords.GenerateJwtToken(username, userDto.Id, jwtKey, pseudoDomain); + return new LoginResult(true, token, false, LoginRejectReason.None); + } + + // An existing user, verify the password + if (userDto is not null) + { + if (Users.VerifyPassword(password, userDto.PasswordHash, + Convert.FromBase64String(userDto.PasswordSalt))) + { + var token = Passwords.GenerateJwtToken(username, userDto.Id, jwtKey, pseudoDomain); + return new LoginResult(true, token, false, LoginRejectReason.None); + } + } + + return new LoginResult(false, Passwords.CreateMessageString(LoginRejectReason.InvalidUsernameOrPassword), true, LoginRejectReason.InvalidUsernameOrPassword); + } } \ No newline at end of file diff --git a/PasswordKeeper.Classes/PasswordKeeper.Classes.csproj b/PasswordKeeper.Classes/PasswordKeeper.Classes.csproj new file mode 100644 index 0000000..6e17900 --- /dev/null +++ b/PasswordKeeper.Classes/PasswordKeeper.Classes.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/PasswordKeeper.Classes/Passwords.cs b/PasswordKeeper.Classes/Passwords.cs new file mode 100644 index 0000000..13ddfe6 --- /dev/null +++ b/PasswordKeeper.Classes/Passwords.cs @@ -0,0 +1,184 @@ +using System.Collections.ObjectModel; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; +using PasswordKeeper.Interfaces.Enumerations; + +namespace PasswordKeeper.Classes; + +/// +/// Password-related helper methods. +/// +public static class Passwords +{ + /// + /// Generates a JWT token for the given username. + /// + /// The username to generate the JWT token for. + /// 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. + /// The JWT token. + public static string GenerateJwtToken(string username, long userId, byte[] jwtKey, string pseudoDomain) + { + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, username), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.NameId, userId.ToString()), + }; + + var key = new SymmetricSecurityKey(jwtKey); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: pseudoDomain, + audience: pseudoDomain, + claims: claims, + expires: DateTime.Now.AddMinutes(30), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + /// + /// Checks if the given password is valid according to the following rules: + /// + /// Must be at least 8 characters long. + /// Must contain at least one uppercase letter. + /// Must contain at least one lowercase letter. + /// Must contain at least one number. + /// Must contain at least one special character. + /// + /// + /// The password to check. + /// The error message if the password is not valid. + /// The reason why the password is not valid. + /// true if the password is valid according to the rules; otherwise, false. + public static bool IsPasswordOk(string password, out string message, out LoginRejectReason reason) + { + const string specialCharacters = @"!@#$%^&*()_+/\-=[]{}|;:,.<>?~"; + + if (password.Length < 8) + { + message = PasswordMustBeAtLeast8CharactersLong; + reason = LoginRejectReason.PasswordMustBeAtLeast8Characters; + return false; + } + + if (!password.Any(char.IsUpper)) + { + message = PasswordMustContainAtLeastOneUppercaseLetter; + reason = LoginRejectReason.PasswordMustContainAtLeastOneUppercaseLetter; + return false; + } + + if (!password.Any(char.IsLower)) + { + message = PasswordMustContainAtLeastOneLowercaseLetter; + reason = LoginRejectReason.PasswordMustContainAtLeastOneLowercaseLetter; + return false; + } + + if (!password.Any(char.IsDigit)) + { + message = PasswordMustContainAtLeastOneNumber; + reason = LoginRejectReason.PasswordMustContainAtLeastOneDigit; + return false; + } + + if (!password.Any(c => specialCharacters.Contains(c))) + { + message = PasswordMustContainAtLeastOneSpecialCharacter; + reason = LoginRejectReason.PasswordMustContainAtLeastOneSpecialCharacter; + return false; + } + + message = string.Empty; + reason = LoginRejectReason.Unauthorized; + + return true; + } + + /// + /// Creates a message string for the given login reject reason. + /// + /// The login reject reason. + /// The message string. + public static string CreateMessageString(LoginRejectReason reason) + { + return $"{reason}: {LoginRejectReasonMessages[reason]}"; + } + + /// + /// An error message indicating that the password must be at least 8 characters long. + /// + public const string PasswordMustBeAtLeast8CharactersLong = "Password must be at least 8 characters long."; + + /// + /// An error message indicating that the password must contain at least one uppercase letter. + /// + public const string PasswordMustContainAtLeastOneUppercaseLetter = "Password must contain at least one uppercase letter."; + + /// + /// An error message indicating that the password must contain at least one lowercase letter. + /// + public const string PasswordMustContainAtLeastOneLowercaseLetter = "Password must contain at least one lowercase letter."; + + /// + /// An error message indicating that the password must contain at least one number. + /// + public const string PasswordMustContainAtLeastOneNumber = "Password must contain at least one number."; + + /// + /// An error message indicating that the password must contain at least one special character. + /// + public const string PasswordMustContainAtLeastOneSpecialCharacter = "Password must contain at least one special character."; + + /// + /// An error message indicating that the username must be at least 4 characters long. + /// + public const string UsernameMustBeAtLeast4CharactersLong = "Username must be at least 4 characters long."; + + /// + /// An error message indicating that the login attempt was unauthorized. + /// + public const string Unauthorized = "Unauthorized"; + + /// + /// An error message indicating that the admin user could not be created. + /// + public const string FailedToCreateAdminUser = "Failed to create admin user."; + + /// + /// An error message indicating that the username or password is invalid. + /// + public const string InvalidUsernameOrPassword = "Invalid username or password."; + + /// + /// A read-only dictionary mapping login reject reasons to error messages. + /// + public static ReadOnlyDictionary LoginRejectReasonMessages { get; } = new(new Dictionary( + new List>( + [ + new KeyValuePair(LoginRejectReason.None, string.Empty), + new KeyValuePair(LoginRejectReason.Unauthorized, Unauthorized), + new KeyValuePair(LoginRejectReason.PasswordMustBeAtLeast8Characters, + PasswordMustBeAtLeast8CharactersLong), + new KeyValuePair(LoginRejectReason.PasswordMustContainAtLeastOneLowercaseLetter, + PasswordMustContainAtLeastOneLowercaseLetter), + new KeyValuePair(LoginRejectReason.PasswordMustContainAtLeastOneUppercaseLetter, + PasswordMustContainAtLeastOneUppercaseLetter), + new KeyValuePair(LoginRejectReason.PasswordMustContainAtLeastOneDigit, + PasswordMustContainAtLeastOneNumber), + new KeyValuePair(LoginRejectReason.PasswordMustContainAtLeastOneSpecialCharacter, + PasswordMustContainAtLeastOneSpecialCharacter), + new KeyValuePair(LoginRejectReason.UsernameMustBeAtLeast4Characters, + UsernameMustBeAtLeast4CharactersLong), + new KeyValuePair(LoginRejectReason.FailedToCreateAdminUser, + FailedToCreateAdminUser), + new KeyValuePair(LoginRejectReason.InvalidUsernameOrPassword, + InvalidUsernameOrPassword), + new KeyValuePair(LoginRejectReason.NotFound, string.Empty) + ]))); +} \ No newline at end of file diff --git a/PasswordKeeper.DatabaseMigrations/Program.cs b/PasswordKeeper.DatabaseMigrations/Program.cs index 7856dc1..8adbfc1 100644 --- a/PasswordKeeper.DatabaseMigrations/Program.cs +++ b/PasswordKeeper.DatabaseMigrations/Program.cs @@ -25,8 +25,8 @@ public static int Main(string[] args) /// /// The name of the database. /// - [Option(template: "-t|--test", optionType: CommandOptionType.NoValue, description: "Use the test database")] - public bool TestDb { get; } = false; + [Option(template: "-t|--test", Description = "Use the test database")] + public string? TestDbName { get; } = null; /// /// The connection string to use for the database. @@ -39,7 +39,7 @@ public static int Main(string[] args) /// The entry point for the application. /// /// - /// If the option is specified, a test database is used and the connection string is + /// If the option is specified, a test database is used and the connection string is /// inferred from the current working directory. Otherwise, the connection string is read from the command /// line option . The database is created if it doesn't exist, and then the /// migrations are run. @@ -48,9 +48,9 @@ public static int Main(string[] args) public void OnExecute() { string connectionString; - if (TestDb) + if (TestDbName != null) { - connectionString = $"Data Source=./{DatabaseName}.db"; + connectionString = $"Data Source=./{TestDbName}.db"; IsTestDb = true; } else diff --git a/PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs b/PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs new file mode 100644 index 0000000..352c82f --- /dev/null +++ b/PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs @@ -0,0 +1,62 @@ +namespace PasswordKeeper.Interfaces.Enumerations; + +/// +/// An enum for the reasons why a login attempt failed. +/// +public enum LoginRejectReason +{ + /// + /// The login attempt was not rejected. + /// + None = 0, + + /// + /// The login attempt was unauthorized. + /// + Unauthorized = 1, + + /// + /// The password does not meet the complexity requirements for the system. It must be at least 8 characters long. + /// + PasswordMustBeAtLeast8Characters = 2, + + /// + /// The password does not meet the complexity requirements for the system. It must contain at least one lowercase letter. + /// + PasswordMustContainAtLeastOneLowercaseLetter = 3, + + /// + /// The password does not meet the complexity requirements for the system. It must contain at least one uppercase letter. + /// + PasswordMustContainAtLeastOneUppercaseLetter = 4, + + /// + /// The password does not meet the complexity requirements for the system. It must contain at least one digit. + /// + PasswordMustContainAtLeastOneDigit = 5, + + /// + /// The password does not meet the complexity requirements for the system. It must contain at least one special character. + /// + PasswordMustContainAtLeastOneSpecialCharacter = 6, + + /// + /// The username must be at least 4 characters long. + /// + UsernameMustBeAtLeast4Characters = 7, + + /// + /// Failed to create the admin user. + /// + FailedToCreateAdminUser = 8, + + /// + /// The username or password is incorrect. + /// + InvalidUsernameOrPassword = 9, + + /// + /// Data related to the user was not found. + /// + NotFound, +} \ No newline at end of file diff --git a/PasswordKeeper.Server/Controllers/AuthenticationController.cs b/PasswordKeeper.Server/Controllers/AuthenticationController.cs index 6ab83fb..2fedc9a 100644 --- a/PasswordKeeper.Server/Controllers/AuthenticationController.cs +++ b/PasswordKeeper.Server/Controllers/AuthenticationController.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using PasswordKeeper.BusinessLogic; -using PasswordKeeper.DTO; +using PasswordKeeper.Classes; using PasswordKeeper.Server.Controllers.Extensions; namespace PasswordKeeper.Server.Controllers; @@ -38,56 +38,21 @@ public record UserChangeRequest(int UserId, string Username, string Password); [AllowAnonymous] public async Task Login([FromBody] UserLogin user) { - var adminExists = await users.UsersExist(true); - UserDto? userDto = null; - - if (user.Username.Length < 4) - { - return BadRequest("Username must be at least 4 characters long."); - } - - if (!Helpers.IsPasswordOk(user.Password, out var message)) - { - return BadRequest(message); - } + var result = await users.Login(user.Username, user.Password, Program.GetJwtKey(), Program.PseudoDomain); - // If no admin user exists, create one if the username is long enough and the password is valid - if (!adminExists) + if (result.Success) { - byte []? salt = null; - userDto = new UserDto - { - UserName = user.Username, - PasswordHash = Users.HashPassword(user.Password, ref salt), - PasswordSalt = Convert.ToBase64String(salt!), - IsAdmin = true, - }; - - userDto = await users.UpsertUser(userDto); - - if (userDto is null) - { - return BadRequest("Failed to create admin user."); - } - - var token = Helpers.GenerateJwtToken(user.Username, userDto.Id); - return Ok(new { token, }); - } - - // An existing user, verify the password - if (userDto is not null) + return Ok(new { token = result.Message, }); + } + if (result is { Success: false, Unauthorized: false, }) { - if (Users.VerifyPassword(user.Password, userDto.PasswordHash, - Convert.FromBase64String(userDto.PasswordSalt))) - { - var token = Helpers.GenerateJwtToken(user.Username, userDto.Id); - return Ok(new { token, }); - } + return BadRequest(result.Message); } return Unauthorized(); } + /// /// Checks a password against the password complexity requirements. /// @@ -97,7 +62,7 @@ public async Task Login([FromBody] UserLogin user) [HttpPost] public IActionResult PasswordOk([FromBody] string password) { - var result = Helpers.IsPasswordOk(password, out var message); + var result = Passwords.IsPasswordOk(password, out var message, out _); return result ? Ok() : BadRequest(message); } @@ -125,7 +90,7 @@ public async Task UpdateUserPassword([FromBody] UserChangeRequest return Unauthorized(); } - if (!Helpers.IsPasswordOk(user.Password, out var message)) + if (!Passwords.IsPasswordOk(user.Password, out var message, out _)) { return BadRequest(message); } @@ -162,7 +127,7 @@ public async Task UpdateUserName([FromBody] UserChangeRequest use if (user.Username.Length < 4) { - return BadRequest("Username must be at least 4 characters long."); + return BadRequest(Passwords.UsernameMustBeAtLeast4CharactersLong); } userDto.UserName = user.Username; diff --git a/PasswordKeeper.Server/Helpers.cs b/PasswordKeeper.Server/Helpers.cs deleted file mode 100644 index 93a2917..0000000 --- a/PasswordKeeper.Server/Helpers.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using Microsoft.IdentityModel.Tokens; - -namespace PasswordKeeper.Server; - -/// -/// Some static helper methods. -/// -public class Helpers -{ - /// - /// Generates a JWT token for the given username. - /// - /// The username to generate the JWT token for. - /// The user ID to generate the JWT token for. - /// The JWT token. - public static string GenerateJwtToken(string username, long userId) - { - var claims = new[] - { - new Claim(JwtRegisteredClaimNames.Sub, username), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - new Claim(JwtRegisteredClaimNames.NameId, userId.ToString()), - }; - - var key = new SymmetricSecurityKey(Program.JwtKey); - var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - - var token = new JwtSecurityToken( - issuer: Program.PseudoDomain, - audience: Program.PseudoDomain, - claims: claims, - expires: DateTime.Now.AddMinutes(30), - signingCredentials: credentials); - - return new JwtSecurityTokenHandler().WriteToken(token); - } - - - /// - /// Checks if the given password is valid according to the following rules: - /// - /// Must be at least 8 characters long. - /// Must contain at least one uppercase letter. - /// Must contain at least one lowercase letter. - /// Must contain at least one number. - /// Must contain at least one special character. - /// - /// - /// The password to check. - /// The error message if the password is not valid. - /// true if the password is valid according to the rules; otherwise, false. - public static bool IsPasswordOk(string password, out string message) - { - const string specialCharacters = @"!@#$%^&*()_+/\-=[]{}|;:,.<>?~"; - - if (password.Length < 8) - { - message = "Password must be at least 8 characters long."; - return false; - } - - if (!password.Any(char.IsUpper)) - { - message = "Password must contain at least one uppercase letter."; - return false; - } - - if (!password.Any(char.IsLower)) - { - message = "Password must contain at least one lowercase letter."; - return false; - } - - if (!password.Any(char.IsDigit)) - { - message = "Password must contain at least one number."; - return false; - } - - if (!password.Any(c => specialCharacters.Contains(c))) - { - message = "Password must contain at least one special character."; - return false; - } - - message = string.Empty; - - return true; - } -} \ No newline at end of file diff --git a/PasswordKeeper.Server/PasswordKeeper.Server.csproj b/PasswordKeeper.Server/PasswordKeeper.Server.csproj index 392ab5d..88c945b 100644 --- a/PasswordKeeper.Server/PasswordKeeper.Server.csproj +++ b/PasswordKeeper.Server/PasswordKeeper.Server.csproj @@ -11,10 +11,10 @@ - + - + @@ -25,6 +25,7 @@ + diff --git a/PasswordKeeper.Server/Program.cs b/PasswordKeeper.Server/Program.cs index 7916499..e90ea18 100644 --- a/PasswordKeeper.Server/Program.cs +++ b/PasswordKeeper.Server/Program.cs @@ -41,7 +41,7 @@ public static void Main(string[] args) ValidateIssuerSigningKey = true, ValidIssuer = Program.PseudoDomain, ValidAudience = Program.PseudoDomain, - IssuerSigningKey = new SymmetricSecurityKey(JwtKey), + IssuerSigningKey = new SymmetricSecurityKey(GetJwtKey()), }; }); @@ -96,55 +96,52 @@ public static void Main(string[] args) private static byte[]? _jwtKey; internal const string PseudoDomain = "password_keeper_server.com"; - + /// - /// A property to get the JWT key. If the database is empty, a new random key is generated and stored there. + /// Gets the JWT key from the database or generates a new one if it is not in the database. /// - internal static byte[] JwtKey + public static Func GetJwtKey = () => { - get + // If the key is already in memory, return it + if (_jwtKey == null) { - // If the key is already in memory, return it - if (_jwtKey == null) + // Check the database for the key + using var connection = new MySql.Data.MySqlClient.MySqlConnection(_connectionString); + connection.Open(); + + using var command = connection.CreateCommand(); + + command.CommandText = @"SELECT JwtSecurityKey FROM KeyData"; + var key = (string?)command.ExecuteScalar(); + if (key != null) { - // Check the database for the key - using var connection = new MySql.Data.MySqlClient.MySqlConnection(_connectionString); - connection.Open(); - - using var command = connection.CreateCommand(); - - command.CommandText = @"SELECT JwtSecurityKey FROM KeyData"; - var key = (string?)command.ExecuteScalar(); - if (key != null) - { - // If the key is in the database, use it - _jwtKey = Convert.FromBase64String(key); - connection.Close(); - return _jwtKey; - } - - // If the key is not in the database, generate a new one - var randomBytes = new byte[32]; - using var rng = RandomNumberGenerator.Create(); - rng.GetBytes(randomBytes); - _jwtKey = randomBytes; - - // Store the key in the database - using var insertKeyCommand = connection.CreateCommand(); - insertKeyCommand.CommandText = "INSERT INTO KeyData (JwtSecurityKey) VALUES (@key)"; - insertKeyCommand.Parameters.Add(new MySqlParameter - { - ParameterName = "@key", - Value = Convert.ToBase64String(_jwtKey), - MySqlDbType = MySqlDbType.Text, - }); - - insertKeyCommand.ExecuteNonQuery(); + // If the key is in the database, use it + _jwtKey = Convert.FromBase64String(key); connection.Close(); - } - - // Return the key - return _jwtKey; + return _jwtKey; + } + + // If the key is not in the database, generate a new one + var randomBytes = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + _jwtKey = randomBytes; + + // Store the key in the database + using var insertKeyCommand = connection.CreateCommand(); + insertKeyCommand.CommandText = "INSERT INTO KeyData (JwtSecurityKey) VALUES (@key)"; + insertKeyCommand.Parameters.Add(new MySqlParameter + { + ParameterName = "@key", + Value = Convert.ToBase64String(_jwtKey), + MySqlDbType = MySqlDbType.Text, + }); + + insertKeyCommand.ExecuteNonQuery(); + connection.Close(); } - } + + // Return the key + return _jwtKey; + }; } \ No newline at end of file diff --git a/PasswordKeeper.Tests/ControllerTests.cs b/PasswordKeeper.Tests/ControllerTests.cs new file mode 100644 index 0000000..f961a15 --- /dev/null +++ b/PasswordKeeper.Tests/ControllerTests.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using PasswordKeeper.Classes; +using PasswordKeeper.DatabaseMigrations; +using PasswordKeeper.Interfaces.Enumerations; +using PasswordKeeper.Server.Controllers; + +namespace PasswordKeeper.Tests; + +/// +/// Tests the api controllers. +/// +public class ControllerTests +{ + /// + /// Sets up the test environment before each test. + /// + [SetUp] + public void Setup() + { + Helpers.DeleteDatabase(nameof(ControllerTests)); + Program.Main([$"-t {nameof(ControllerTests)}",]); + Server.Program.GetJwtKey = Helpers.GetJwtKey; + } + + /// + /// Tests the authentication controller. + /// + [Test] + public async Task AuthenticationControllerTest() + { + var dataAccess = new PasswordKeeper.DataAccess.Users(Helpers.GetMockDbContextFactory(nameof(ControllerTests)), Helpers.CreateMapper()); + var businessLogic = new PasswordKeeper.BusinessLogic.Users(dataAccess); + var controller = new AuthenticationController(businessLogic); + var loginData = new AuthenticationController.UserLogin("firsUserIsAdmin", "password"); + var result = await controller.Login(loginData); + + // The first request should fail as the password does not meet the complexity requirements + var value = ((BadRequestObjectResult)result).Value!.ToString(); + Assert.That(result, Is.TypeOf()); + Assert.That(value, Is.EqualTo(Passwords.CreateMessageString(LoginRejectReason.PasswordMustContainAtLeastOneUppercaseLetter))); + + // The second one should succeed as the password meets the complexity requirements + loginData = new AuthenticationController.UserLogin("firsUserIsAdmin", "Pa1sword%"); + result = await controller.Login(loginData); + Assert.That(result, Is.TypeOf()); + } +} \ No newline at end of file diff --git a/PasswordKeeper.Tests/DatabaseTests.cs b/PasswordKeeper.Tests/DatabaseTests.cs index 1e1f99f..385f703 100644 --- a/PasswordKeeper.Tests/DatabaseTests.cs +++ b/PasswordKeeper.Tests/DatabaseTests.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using PasswordKeeper.DAO; using PasswordKeeper.DatabaseMigrations; namespace PasswordKeeper.Tests; @@ -15,6 +14,8 @@ public class DatabaseTests [SetUp] public void Setup() { + Helpers.DeleteDatabase(nameof(DatabaseTests)); + Program.Main([$"-t {nameof(DatabaseTests)}",]); } /// @@ -23,35 +24,9 @@ public void Setup() [Test] public async Task UseEfCoreTest() { - DeleteDatabase(); - Program.Main(["-t"]); - await using var context = GetMemoryContext(); + var dbContextFactory = Helpers.GetMockDbContextFactory(nameof(DatabaseTests)); + await using var context = await dbContextFactory.CreateDbContextAsync(); _ = await context.Users.CountAsync(); // TODO::More tests } - - /// - /// Creates a new SQLite database context. - /// - /// The database context. - private static Entities GetMemoryContext() - { - var options = new DbContextOptionsBuilder() - .UseSqlite($"Data Source=./{Program.DatabaseName}.db") - .Options; - - return new Entities(options); - } - - /// - /// Deletes the database file. - /// - private static void DeleteDatabase() - { - var dbFile = $"./{PasswordKeeper.DatabaseMigrations.Program.DatabaseName}.db"; - if (File.Exists(dbFile)) - { - File.Delete(dbFile); - } - } } \ No newline at end of file diff --git a/PasswordKeeper.Tests/Helpers.cs b/PasswordKeeper.Tests/Helpers.cs new file mode 100644 index 0000000..2b88cb2 --- /dev/null +++ b/PasswordKeeper.Tests/Helpers.cs @@ -0,0 +1,80 @@ +using System.Security.Cryptography; +using AutoMapper; +using Microsoft.EntityFrameworkCore; +using PasswordKeeper.DAO; +using PasswordKeeper.DataAccess; + +namespace PasswordKeeper.Tests; + +/// +/// Helper methods for the 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($"Data Source=./{testClassName}.db") + .Options; + + return new Entities(options); + } + + /// + /// Creates a new SQLite database context factory. + /// + /// The name of the test class. + /// The database context. + public static IDbContextFactory GetMockDbContextFactory(string testClassName) + { + return new MockDbContextFactory(testClassName); + } + + /// + /// Deletes the database file. + /// + /// The name of the test class. + public static void DeleteDatabase(string testClassName) + { + var dbFile = $"./{testClassName}.db"; + if (File.Exists(dbFile)) + { + File.Delete(dbFile); + } + } + + /// + /// Creates an instance to a class implementing the interface. + /// + /// An instance to a class implementing the interface. + public static IMapper CreateMapper() + { + var config = new MapperConfiguration(cfg => cfg.AddProfile()); + return config.CreateMapper(); + } + + private static byte[]? _jwtKey; + + /// + /// Gets the mock JWT key. + /// + /// The mock JWT key. + public static byte[] GetJwtKey() + { + if (_jwtKey == null) + { + // If the key is not in the database, generate a new one + var randomBytes = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + _jwtKey = randomBytes; + } + + return _jwtKey; + } +} \ No newline at end of file diff --git a/PasswordKeeper.Tests/MockDbContextFactory.cs b/PasswordKeeper.Tests/MockDbContextFactory.cs new file mode 100644 index 0000000..46096a5 --- /dev/null +++ b/PasswordKeeper.Tests/MockDbContextFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using PasswordKeeper.DAO; + +namespace PasswordKeeper.Tests; + +/// +/// A mock database context factory. +/// +/// The name of the test class. +/// +public class MockDbContextFactory(string testClassName) : IDbContextFactory +{ + /// + /// Creates a new SQLite database context. + /// + /// The database context. + public Entities CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source=./{testClassName}.db") + .Options; + + return new Entities(options); + } +} \ No newline at end of file diff --git a/PasswordKeeper.Tests/PasswordKeeper.Tests.csproj b/PasswordKeeper.Tests/PasswordKeeper.Tests.csproj index f6fc623..80dc29f 100644 --- a/PasswordKeeper.Tests/PasswordKeeper.Tests.csproj +++ b/PasswordKeeper.Tests/PasswordKeeper.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/PasswordKeeperServer.sln b/PasswordKeeperServer.sln index d7223a2..c882235 100644 --- a/PasswordKeeperServer.sln +++ b/PasswordKeeperServer.sln @@ -24,6 +24,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PasswordKeeper.Tests", "Pas EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PasswordKeeper.BusinessLogic", "PasswordKeeper.BusinessLogic\PasswordKeeper.BusinessLogic.csproj", "{EFF3BB4C-3CA3-40D0-BEF8-2D13AC4CD901}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PasswordKeeper.Classes", "PasswordKeeper.Classes\PasswordKeeper.Classes.csproj", "{0B6C9609-9EA0-425B-9CDF-CF5D19DE7045}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -62,5 +64,9 @@ Global {EFF3BB4C-3CA3-40D0-BEF8-2D13AC4CD901}.Debug|Any CPU.Build.0 = Debug|Any CPU {EFF3BB4C-3CA3-40D0-BEF8-2D13AC4CD901}.Release|Any CPU.ActiveCfg = Release|Any CPU {EFF3BB4C-3CA3-40D0-BEF8-2D13AC4CD901}.Release|Any CPU.Build.0 = Release|Any CPU + {0B6C9609-9EA0-425B-9CDF-CF5D19DE7045}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0B6C9609-9EA0-425B-9CDF-CF5D19DE7045}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0B6C9609-9EA0-425B-9CDF-CF5D19DE7045}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0B6C9609-9EA0-425B-9CDF-CF5D19DE7045}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal