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
4 changes: 2 additions & 2 deletions PasswordKeeper.BusinessLogic/Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class Users(PasswordKeeper.DataAccess.Users users)
}

/// <inheritdoc cref="PasswordKeeper.DataAccess.Users.GetUserById"/>
public async Task<UserDto?> GetUserById(int id)
public async Task<UserDto?> GetUserById(long id)
{
return await users.GetUserById(id);
}
Expand Down Expand Up @@ -113,7 +113,7 @@ public async Task<LoginResult> 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,
Expand Down
8 changes: 7 additions & 1 deletion PasswordKeeper.Classes/Passwords.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ public static string CreateMessageString(LoginRejectReason reason)
/// An error message indicating that the username or password is invalid.
/// </summary>
public const string InvalidUsernameOrPassword = "Invalid username or password.";

/// <summary>
/// An error message indicating that the user already exists.
/// </summary>
public const string UserAlreadyExists = "User already exists.";

/// <summary>
/// A read-only dictionary mapping login reject reasons to error messages.
Expand All @@ -179,6 +184,7 @@ public static string CreateMessageString(LoginRejectReason reason)
FailedToCreateAdminUser),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.InvalidUsernameOrPassword,
InvalidUsernameOrPassword),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.NotFound, string.Empty)
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.NotFound, string.Empty),
new KeyValuePair<LoginRejectReason, string>(LoginRejectReason.UserAlreadyExists, UserAlreadyExists),
])));
}
4 changes: 2 additions & 2 deletions PasswordKeeper.DAO/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ public class User : IUser
/// <inheritdoc cref="IHasId.Id" />
public long Id { get; set; }

/// <inheritdoc cref="IUser.UserName" />
/// <inheritdoc cref="IUser.Username" />
[MaxLength(255)]
public string UserName { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;

/// <inheritdoc cref="IUser.PasswordHash" />
[MaxLength(1000)]
Expand Down
4 changes: 2 additions & 2 deletions PasswordKeeper.DTO/UserDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ public class UserDto : IUser
/// <inheritdoc cref="IHasId.Id" />
public long Id { get; set; }

/// <inheritdoc cref="IUser.UserName" />
public string UserName { get; set; } = string.Empty;
/// <inheritdoc cref="IUser.Username" />
public string Username { get; set; } = string.Empty;

/// <inheritdoc cref="IUser.PasswordHash" />
[JsonIgnore]
Expand Down
6 changes: 3 additions & 3 deletions PasswordKeeper.DataAccess/Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class Users(IDbContextFactory<Entities> 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<UserDto?>(user);
}
Expand All @@ -31,7 +31,7 @@ public class Users(IDbContextFactory<Entities> dbContextFactory, IMapper mapper)
/// </summary>
/// <param name="id">The user ID to search for.</param>
/// <returns>The user with the given ID, or null if it doesn't exist.</returns>
public async Task<UserDto?> GetUserById(int id)
public async Task<UserDto?> GetUserById(long id)
{
await using var context = await dbContextFactory.CreateDbContextAsync();

Expand Down Expand Up @@ -67,7 +67,7 @@ public async Task<bool> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion PasswordKeeper.DatabaseMigrations/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion PasswordKeeper.Interfaces/Enumerations/LoginRejectReason.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,10 @@ public enum LoginRejectReason
/// <summary>
/// Data related to the user was not found.
/// </summary>
NotFound,
NotFound = 10,

/// <summary>
/// The user already exists.
/// </summary>
UserAlreadyExists = 11,
}
2 changes: 1 addition & 1 deletion PasswordKeeper.Interfaces/IUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public interface IUser : IHasId
/// <summary>
/// The name of the user.
/// </summary>
string UserName { get; set; }
string Username { get; set; }

/// <summary>
/// The password of the user.
Expand Down
56 changes: 55 additions & 1 deletion PasswordKeeper.Server/Controllers/AuthenticationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -130,12 +131,65 @@ public async Task<IActionResult> 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);
}

/// <summary>
/// Creates a new user if the requester is admin.
/// </summary>
/// <param name="user">The user data to create.</param>
/// <returns>
/// Unauthorized if the requester is not admin, BadRequest if the upsert operation fails,
/// otherwise Ok with the created user data.
/// </returns>
public async Task<IActionResult> 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();
}

/// <summary>
/// An endpoint for testing unauthorized access.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,41 @@ public static class ControllerExtensions
/// <returns>The logged-in user's ID, or -1 if the claim containing the user ID is not found.</returns>
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);
}

/// <summary>
/// A delegate to get the logged-in user's ID. This is called by the <see cref="GetLoggedUserId"/> method.
/// </summary>
/// <remarks>A delegate is used to allow unit testing.</remarks>
public static Func<ControllerBase, long> 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;
}
};

/// <summary>
/// Gets the logged-in user's name.
/// </summary>
/// <returns>The logged-in user's name, or an empty string if the user is not logged in.</returns>
public static string GetLoggedUserName(this ControllerBase controllerBase)
{
return controllerBase.User.Identity?.Name ?? string.Empty;
return GetLoggedUserNameFunc(controllerBase);
}

/// <summary>
/// A delegate to get the logged-in user's name. This is called by the <see cref="GetLoggedUserName"/> method.
/// </summary>
/// <remarks>A delegate is used to allow unit testing.</remarks>
public static Func<ControllerBase, string> GetLoggedUserNameFunc = controllerBase =>
{
return controllerBase.User.Identity?.Name ?? string.Empty;
};
}
36 changes: 33 additions & 3 deletions PasswordKeeper.Tests/ControllerTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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";
}

/// <summary>
/// Tests the authentication controller.
/// Tests the AuthenticationController.Login action.
/// </summary>
[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");
Expand All @@ -43,5 +48,30 @@ public async Task AuthenticationControllerTest()
loginData = new AuthenticationController.UserLogin("firsUserIsAdmin", "Pa1sword%");
result = await controller.Login(loginData);
Assert.That(result, Is.TypeOf<OkObjectResult>());
await dbContextFactory.DisposeAsync();
}

/// <summary>
/// Tests the AuthenticationController.CreateUser action.
/// </summary>
[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<OkObjectResult>());
var userDto = ((OkObjectResult)createdUser).Value as UserDto;
Assert.That(userDto, Is.TypeOf<UserDto>());
Assert.That(userDto.Username, Is.EqualTo("normalUser"));
await dbContextFactory.DisposeAsync();
}
}
8 changes: 4 additions & 4 deletions PasswordKeeper.Tests/Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static class Helpers
public static Entities GetMemoryContext(string testClassName)
{
var options = new DbContextOptionsBuilder<Entities>()
.UseSqlite($"Data Source=./{testClassName}.db")
.UseSqlite($"Data Source=./{testClassName}.db;Pooling=False;")
.Options;

return new Entities(options);
Expand All @@ -30,11 +30,11 @@ public static Entities GetMemoryContext(string testClassName)
/// </summary>
/// <param name="testClassName">The name of the test class.</param>
/// <returns>The database context.</returns>
public static IDbContextFactory<Entities> GetMockDbContextFactory(string testClassName)
public static IDisposableContextFactory<Entities> GetMockDbContextFactory(string testClassName)
{
return new MockDbContextFactory(testClassName);
}

/// <summary>
/// Deletes the database file.
/// </summary>
Expand All @@ -47,7 +47,7 @@ public static void DeleteDatabase(string testClassName)
File.Delete(dbFile);
}
}

/// <summary>
/// Creates an instance to a class implementing the <see cref="IMapper"/> interface.
/// </summary>
Expand Down
13 changes: 13 additions & 0 deletions PasswordKeeper.Tests/IDisposableContextFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.EntityFrameworkCore;

namespace PasswordKeeper.Tests;

/// <summary>
/// A disposable database context factory.
/// </summary>
/// <typeparam name="T">The type of the database context.</typeparam>
/// <seealso cref="IDbContextFactory{T}" />
/// <seealso cref="IDisposable" />
/// <seealso cref="IAsyncDisposable" />
public interface IDisposableContextFactory<T> : IDbContextFactory<T>, IDisposable, IAsyncDisposable
where T : DbContext;
Loading