Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PasswordKeeper.Classes\PasswordKeeper.Classes.csproj" />
<ProjectReference Include="..\PasswordKeeper.DataAccess\PasswordKeeper.DataAccess.csproj" />
</ItemGroup>

Expand Down
71 changes: 71 additions & 0 deletions PasswordKeeper.BusinessLogic/Users.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -72,4 +74,73 @@ public static bool VerifyPassword(string password, string hashedPassword, byte[]

return verifyHash == hashedPassword;
}

/// <summary>
/// Login result.
/// </summary>
/// <param name="Success">Whether the login was successful.</param>
/// <param name="Message">The message associated with the login result.</param>
/// <param name="Unauthorized">Whether the login was unauthorized.</param>
/// <param name="Reason">The reason for the login rejection.</param>
public record LoginResult(bool Success, string Message, bool Unauthorized, LoginRejectReason Reason);

/// <summary>
/// Logs in the user and returns a JWT token.
/// </summary>
/// <param name="username">The username.</param>
/// <param name="password">The password.</param>
/// <param name="jwtKey">The JWT key.</param>
/// <param name="pseudoDomain">The pseudo domain.</param>
/// <returns>A JWT token if the login is valid, otherwise an error message.</returns>
public async Task<LoginResult> 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);
}
}
17 changes: 17 additions & 0 deletions PasswordKeeper.Classes/PasswordKeeper.Classes.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PasswordKeeper.Interfaces\PasswordKeeper.Interfaces.csproj" />
</ItemGroup>

</Project>
184 changes: 184 additions & 0 deletions PasswordKeeper.Classes/Passwords.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Password-related helper methods.
/// </summary>
public static class Passwords
{
/// <summary>
/// Generates a JWT token for the given username.
/// </summary>
/// <param name="username">The username to generate the JWT token for.</param>
/// <param name="userId">The user ID to generate the JWT token for.</param>
/// <param name="jwtKey">The JWT key to use for signing the token.</param>
/// <param name="pseudoDomain">The pseudo domain to use for the token issuer and audience.</param>
/// <returns>The JWT token.</returns>
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);
}

/// <summary>
/// Checks if the given password is valid according to the following rules:
/// <list type="bullet">
/// <item>Must be at least 8 characters long.</item>
/// <item>Must contain at least one uppercase letter.</item>
/// <item>Must contain at least one lowercase letter.</item>
/// <item>Must contain at least one number.</item>
/// <item>Must contain at least one special character.</item>
/// </list>
/// </summary>
/// <param name="password">The password to check.</param>
/// <param name="message">The error message if the password is not valid.</param>
/// <param name="reason">The reason why the password is not valid.</param>
/// <returns><c>true</c> if the password is valid according to the rules; otherwise, <c>false</c>.</returns>
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;
}

/// <summary>
/// Creates a message string for the given login reject reason.
/// </summary>
/// <param name="reason">The login reject reason.</param>
/// <returns>The message string.</returns>
public static string CreateMessageString(LoginRejectReason reason)
{
return $"{reason}: {LoginRejectReasonMessages[reason]}";
}

/// <summary>
/// An error message indicating that the password must be at least 8 characters long.
/// </summary>
public const string PasswordMustBeAtLeast8CharactersLong = "Password must be at least 8 characters long.";

/// <summary>
/// An error message indicating that the password must contain at least one uppercase letter.
/// </summary>
public const string PasswordMustContainAtLeastOneUppercaseLetter = "Password must contain at least one uppercase letter.";

/// <summary>
/// An error message indicating that the password must contain at least one lowercase letter.
/// </summary>
public const string PasswordMustContainAtLeastOneLowercaseLetter = "Password must contain at least one lowercase letter.";

/// <summary>
/// An error message indicating that the password must contain at least one number.
/// </summary>
public const string PasswordMustContainAtLeastOneNumber = "Password must contain at least one number.";

/// <summary>
/// An error message indicating that the password must contain at least one special character.
/// </summary>
public const string PasswordMustContainAtLeastOneSpecialCharacter = "Password must contain at least one special character.";

/// <summary>
/// An error message indicating that the username must be at least 4 characters long.
/// </summary>
public const string UsernameMustBeAtLeast4CharactersLong = "Username must be at least 4 characters long.";

/// <summary>
/// An error message indicating that the login attempt was unauthorized.
/// </summary>
public const string Unauthorized = "Unauthorized";

/// <summary>
/// An error message indicating that the admin user could not be created.
/// </summary>
public const string FailedToCreateAdminUser = "Failed to create admin user.";

/// <summary>
/// An error message indicating that the username or password is invalid.
/// </summary>
public const string InvalidUsernameOrPassword = "Invalid username or password.";

/// <summary>
/// A read-only dictionary mapping login reject reasons to error messages.
/// </summary>
public static ReadOnlyDictionary<LoginRejectReason, string> LoginRejectReasonMessages { get; } = new(new Dictionary<LoginRejectReason, string>(
new List<KeyValuePair<LoginRejectReason, string>>(
[
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.None, string.Empty),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.Unauthorized, Unauthorized),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.PasswordMustBeAtLeast8Characters,
PasswordMustBeAtLeast8CharactersLong),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.PasswordMustContainAtLeastOneLowercaseLetter,
PasswordMustContainAtLeastOneLowercaseLetter),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.PasswordMustContainAtLeastOneUppercaseLetter,
PasswordMustContainAtLeastOneUppercaseLetter),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.PasswordMustContainAtLeastOneDigit,
PasswordMustContainAtLeastOneNumber),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.PasswordMustContainAtLeastOneSpecialCharacter,
PasswordMustContainAtLeastOneSpecialCharacter),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.UsernameMustBeAtLeast4Characters,
UsernameMustBeAtLeast4CharactersLong),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.FailedToCreateAdminUser,
FailedToCreateAdminUser),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.InvalidUsernameOrPassword,
InvalidUsernameOrPassword),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.NotFound, string.Empty)
])));
}
10 changes: 5 additions & 5 deletions PasswordKeeper.DatabaseMigrations/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ public static int Main(string[] args)
/// <summary>
/// The name of the database.
/// </summary>
[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;

/// <summary>
/// The connection string to use for the database.
Expand All @@ -39,7 +39,7 @@ public static int Main(string[] args)
/// The entry point for the application.
/// </summary>
/// <remarks>
/// If the <see cref="TestDb"/> option is specified, a test database is used and the connection string is
/// If the <see cref="TestDbName"/> 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 <see cref="ConnectionString"/>. The database is created if it doesn't exist, and then the
/// migrations are run.
Expand All @@ -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
Expand Down
62 changes: 62 additions & 0 deletions PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
namespace PasswordKeeper.Interfaces.Enumerations;

/// <summary>
/// An enum for the reasons why a login attempt failed.
/// </summary>
public enum LoginRejectReason
{
/// <summary>
/// The login attempt was not rejected.
/// </summary>
None = 0,

/// <summary>
/// The login attempt was unauthorized.
/// </summary>
Unauthorized = 1,

/// <summary>
/// The password does not meet the complexity requirements for the system. It must be at least 8 characters long.
/// </summary>
PasswordMustBeAtLeast8Characters = 2,

/// <summary>
/// The password does not meet the complexity requirements for the system. It must contain at least one lowercase letter.
/// </summary>
PasswordMustContainAtLeastOneLowercaseLetter = 3,

/// <summary>
/// The password does not meet the complexity requirements for the system. It must contain at least one uppercase letter.
/// </summary>
PasswordMustContainAtLeastOneUppercaseLetter = 4,

/// <summary>
/// The password does not meet the complexity requirements for the system. It must contain at least one digit.
/// </summary>
PasswordMustContainAtLeastOneDigit = 5,

/// <summary>
/// The password does not meet the complexity requirements for the system. It must contain at least one special character.
/// </summary>
PasswordMustContainAtLeastOneSpecialCharacter = 6,

/// <summary>
/// The username must be at least 4 characters long.
/// </summary>
UsernameMustBeAtLeast4Characters = 7,

/// <summary>
/// Failed to create the admin user.
/// </summary>
FailedToCreateAdminUser = 8,

/// <summary>
/// The username or password is incorrect.
/// </summary>
InvalidUsernameOrPassword = 9,

/// <summary>
/// Data related to the user was not found.
/// </summary>
NotFound,
}
Loading