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