diff --git a/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj b/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj new file mode 100644 index 0000000..a233e0d --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/PMDDesktop.Server.Users.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PMDDesktop.Server.Users.Tests/Roles/RoleReferenceTests.cs b/PMDDesktop.Server.Users.Tests/Roles/RoleReferenceTests.cs new file mode 100644 index 0000000..29d8e62 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/Roles/RoleReferenceTests.cs @@ -0,0 +1,51 @@ +using PMDDesktop.Server.Users.Roles; +using System.Text.Json; + +namespace PMDDesktop.Server.Users.Tests.Roles; + +public class RoleReferenceTests +{ + + [Fact] + public async Task CanGetReference() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + UserRole? role = await roleManager.TryCreateRole(); + Assert.NotNull(role); + + RoleReference reference = new(role); + + Assert.True(reference.TryGetRole(manager, out UserRole? referRole)); + + Assert.Equal(role, referRole); + + } + + [Fact] + public async Task SerializationIsSimple() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + UserRole? role = await roleManager.TryCreateRole(); + Assert.NotNull(role); + + RoleReference preReference = new(role); + + JsonElement json = JsonSerializer.SerializeToElement(preReference, AppInfo.JSON_OPTIONS); + + Assert.Equal(JsonValueKind.String, json.ValueKind); + + RoleReference? postReference = JsonSerializer.Deserialize(json); + + Assert.NotNull(postReference); + + Assert.True(postReference.TryGetRole(manager, out UserRole? referRole)); + + Assert.Equal(role, referRole); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserPermissionTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserPermissionTests.cs new file mode 100644 index 0000000..19aa2d3 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/Roles/UserPermissionTests.cs @@ -0,0 +1,18 @@ +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.Users.Tests.Roles; + +public class UserPermissionTests +{ + + [Theory] + [InlineData("manage-users")] + [InlineData("manage-roles")] + public void UserPermissionAllIncludesPermissions(string dataName) + { + + Assert.Contains(UserPermission.ALL, permission => permission.DataName == dataName); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs new file mode 100644 index 0000000..8183305 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleConverterTests.cs @@ -0,0 +1,94 @@ +using PMDDesktop.Server.Users.Roles; +using System.Text.Json; + +namespace PMDDesktop.Server.Users.Tests.Roles; + +public class UserRoleConverterTests +{ + + [Fact] + public async Task RoleJsonContainsNullsForUnspecified() + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_USERS, true); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_ROLES.DataName); + + Assert.Equal(JsonValueKind.Null, manageRolesElement.ValueKind); + + } + + [Fact] + public async Task RoleJsonContainsTrueWhenSpecified() + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_USERS, true); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_USERS.DataName); + + Assert.Equal(JsonValueKind.True, manageRolesElement.ValueKind); + + } + + [Fact] + public async Task RoleJsonContainsFalseWhenSpecified() + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_USERS, false); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_USERS.DataName); + + Assert.Equal(JsonValueKind.False, manageRolesElement.ValueKind); + + } + + [Fact] + public async Task RoleJsonWithSpecifiedPermissionThenUnspecifiedContainsNull() + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_USERS, true); + await role.SetPermission(UserPermission.MANAGE_USERS, null); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + JsonElement manageRolesElement = document.RootElement.GetProperty(UserRoleConverter.PERMISSIONS_PROPERTY).GetProperty(UserPermission.MANAGE_USERS.DataName); + + Assert.Equal(JsonValueKind.Null, manageRolesElement.ValueKind); + + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(null)] + public async Task RoundTripRoleWithSpecifiedPermissionKeepsValue(bool? value) + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_USERS, value); + + JsonDocument document = JsonSerializer.SerializeToDocument(role); + + UserRole? newRole = JsonSerializer.Deserialize(document); + Assert.NotNull(newRole); + + Assert.Equal(value, newRole.GetPermission(UserPermission.MANAGE_USERS)); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleManagerTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleManagerTests.cs new file mode 100644 index 0000000..1373c2f --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleManagerTests.cs @@ -0,0 +1,194 @@ +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.Users.Tests.Roles; + +public class UserRoleManagerTests +{ + + [Fact] + public void UserRoleManagerPointsBackAtUserManager() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + + Assert.NotNull(roleManager); + + Assert.Equal(manager, roleManager.Manager); + + Assert.Equal(roleManager, roleManager.Manager.RoleManager); + + } + + [Fact] + public void UserRoleManagerHasDefaultRole() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + + Assert.NotNull(roleManager.DefaultRole); + + Assert.True(roleManager.DefaultRole.IsDefaultRole); + + Assert.Equal(Guid.Empty, roleManager.DefaultRole.GUID); + + // Seeing if that's what we get for Guid.Empty + + Assert.True(roleManager.TryGetRole(Guid.Empty, out UserRole? tryEmptyRole)); + Assert.NotNull(tryEmptyRole); + Assert.Equal(roleManager.DefaultRole, tryEmptyRole); + + // Test comparing to mock default role. + + UserRole exampleDefault = UserRole.CreateDefaultRole(); + UserRole managerDefault = roleManager.DefaultRole; + + Assert.All(UserPermission.ALL, perm => Assert.Equal(exampleDefault.GetPermission(perm), managerDefault.GetPermission(perm))); + + } + + [Fact] + public async Task NewRolesAreOrderedLastBeforeDefault() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + + // Role 0 + UserRole role0 = roleManager.DefaultRole; + Assert.Equal(role0, roleManager.Skip(0).First()); + Assert.Single(roleManager); + Assert.Equal(role0, roleManager.SkipLast(0).Last()); + + // Role 1 + + UserRole? role1 = await roleManager.TryCreateRole(); + Assert.Equal(role1, roleManager.Skip(0).First()); + Assert.Equal(role0, roleManager.Skip(1).First()); + Assert.Equal(2, roleManager.Count()); + Assert.Equal(role1, roleManager.SkipLast(1).Last()); + Assert.Equal(role0, roleManager.SkipLast(0).Last()); + + // Role 2 + + UserRole? role2 = await roleManager.TryCreateRole(); + Assert.Equal(role1, roleManager.Skip(0).First()); + Assert.Equal(role2, roleManager.Skip(1).First()); + Assert.Equal(role0, roleManager.Skip(2).First()); + Assert.Equal(3, roleManager.Count()); + Assert.Equal(role1, roleManager.SkipLast(2).Last()); + Assert.Equal(role2, roleManager.SkipLast(1).Last()); + Assert.Equal(role0, roleManager.SkipLast(0).Last()); + + // Role 3 + + UserRole? role3 = await roleManager.TryCreateRole(); + Assert.Equal(role1, roleManager.Skip(0).First()); + Assert.Equal(role2, roleManager.Skip(1).First()); + Assert.Equal(role3, roleManager.Skip(2).First()); + Assert.Equal(role0, roleManager.Skip(3).First()); + Assert.Equal(4, roleManager.Count()); + Assert.Equal(role1, roleManager.SkipLast(3).Last()); + Assert.Equal(role2, roleManager.SkipLast(2).Last()); + Assert.Equal(role3, roleManager.SkipLast(1).Last()); + Assert.Equal(role0, roleManager.SkipLast(0).Last()); + + // Role 4 + + UserRole? role4 = await roleManager.TryCreateRole(); + Assert.Equal(role1, roleManager.Skip(0).First()); + Assert.Equal(role2, roleManager.Skip(1).First()); + Assert.Equal(role3, roleManager.Skip(2).First()); + Assert.Equal(role4, roleManager.Skip(3).First()); + Assert.Equal(role0, roleManager.Skip(4).First()); + Assert.Equal(5, roleManager.Count()); + Assert.Equal(role1, roleManager.SkipLast(4).Last()); + Assert.Equal(role2, roleManager.SkipLast(3).Last()); + Assert.Equal(role3, roleManager.SkipLast(2).Last()); + Assert.Equal(role4, roleManager.SkipLast(1).Last()); + Assert.Equal(role0, roleManager.SkipLast(0).Last()); + + } + + [Fact] + public async Task SortingAffectsEnumeration() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + + UserRole defaultRole = roleManager.DefaultRole; + UserRole? role1 = await roleManager.TryCreateRole(); + UserRole? role2 = await roleManager.TryCreateRole(); + UserRole? role3 = await roleManager.TryCreateRole(); + UserRole? role4 = await roleManager.TryCreateRole(); + + Assert.NotNull(role1); + Assert.NotNull(role2); + Assert.NotNull(role3); + Assert.NotNull(role4); + + UserRole[] array1 = [.. roleManager]; + + Assert.Equal(role1, array1[0]); + Assert.Equal(role2, array1[1]); + Assert.Equal(role3, array1[2]); + Assert.Equal(role4, array1[3]); + Assert.Equal(defaultRole, array1[4]); + + UserRole[] newSort2 = [role3, role2, role1, role4]; + + await roleManager.SetOrder(newSort2.Select(role => role.GUID)); + + UserRole[] array2 = [.. roleManager]; + + Assert.Equal(newSort2[0], array2[0]); + Assert.Equal(newSort2[1], array2[1]); + Assert.Equal(newSort2[2], array2[2]); + Assert.Equal(newSort2[3], array2[3]); + Assert.Equal(defaultRole, array2[4]); + + } + + [Fact] + public async Task SortingAffectsEnumerationWithDefaultSpecified() + { + + UserManager manager = new(); + UserRoleManager roleManager = manager.RoleManager; + + UserRole defaultRole = roleManager.DefaultRole; + UserRole? role1 = await roleManager.TryCreateRole(); + UserRole? role2 = await roleManager.TryCreateRole(); + UserRole? role3 = await roleManager.TryCreateRole(); + UserRole? role4 = await roleManager.TryCreateRole(); + + Assert.NotNull(role1); + Assert.NotNull(role2); + Assert.NotNull(role3); + Assert.NotNull(role4); + + UserRole[] array1 = [.. roleManager]; + + Assert.Equal(role1, array1[0]); + Assert.Equal(role2, array1[1]); + Assert.Equal(role3, array1[2]); + Assert.Equal(role4, array1[3]); + Assert.Equal(defaultRole, array1[4]); + + UserRole[] newSort2 = [role3, role2, role1, role4, defaultRole]; + + await roleManager.SetOrder(newSort2.Select(role => role.GUID)); + + UserRole[] array2 = [.. roleManager]; + + Assert.Equal(newSort2[0], array2[0]); + Assert.Equal(newSort2[1], array2[1]); + Assert.Equal(newSort2[2], array2[2]); + Assert.Equal(newSort2[3], array2[3]); + Assert.Equal(newSort2[4], array2[4]); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs new file mode 100644 index 0000000..16c0136 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/Roles/UserRoleTests.cs @@ -0,0 +1,63 @@ +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.Users.Tests.Roles; + +public class UserRoleTests +{ + + [Theory] + [InlineData(true)] + [InlineData(false)] + [InlineData(null)] + public async Task SetPermissionsAndReturnExpectedPermissions(bool? value) + { + + UserRole role = new(); + + await role.SetPermission(UserPermission.MANAGE_ROLES, value); + + Assert.Equal(value, role.GetPermission(UserPermission.MANAGE_ROLES)); + + } + + [Fact] + public async Task UnsetPermissionsAreNull() + { + + UserRole role = new(); + + Assert.All(UserPermission.ALL, permission => Assert.Null(role.GetPermission(permission))); + + } + + [Fact] + public void DefaultRoleHasAllEnabledByDefaultPermissions() + { + + UserRole role = UserRole.CreateDefaultRole(); + + Assert.All(UserPermission.ALL.Where(perm => perm.EnabledForDefault), perm => Assert.True(role.GetPermission(perm))); + + } + + [Fact] + public void DefaultRoleHasNoDisabledByDefaultPermissions() + { + + UserRole role = UserRole.CreateDefaultRole(); + + Assert.All(UserPermission.ALL.Where(perm => !perm.EnabledForDefault), perm => Assert.Null(role.GetPermission(perm))); + + } + + [Fact] + public void DefaultRoleIsMarkedAsDefaultRole() + { + + UserRole role = UserRole.CreateDefaultRole(); + + Assert.True(role.IsDefaultRole); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/UserConverterTests.cs b/PMDDesktop.Server.Users.Tests/UserConverterTests.cs new file mode 100644 index 0000000..2643302 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/UserConverterTests.cs @@ -0,0 +1,80 @@ +using System.Text.Json; + +namespace PMDDesktop.Server.Users.Tests; + +public class UserConverterTests +{ + + public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new() + { + DisplayName = "Test User", + LoginHandle = "test", + PlainTextPassword = "Test123" + }; + + [Fact] + public async Task ToAndFromJsonKeepsName() + { + + UserManager manager1 = new(); + User? user1 = await manager1.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user1); + + using MemoryStream jsonStream = new(); + + await JsonSerializer.SerializeAsync(jsonStream, user1, AppInfo.JSON_OPTIONS); + + jsonStream.Position = 0; // Rewind the stream so it can be read. + + UserManager manager2 = new(); + User user2 = await manager2.LoadAndAddUserJson(jsonStream, user1.GUID); + + Assert.Equal(user1.GUID, user2.GUID); + Assert.Equal(user1.Name, user2.Name); + Assert.Equal(user1.HashedPassword, user2.HashedPassword); + Assert.Equal(user1.LoginHandle, user2.LoginHandle); + + } + + [Fact] + public async Task ToAndFromJsonReattachesTokens() + { + + UserManager manager1 = new(); + User? user1 = await manager1.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user1); + + for (int i = 0; i < 100; ++i) // Creating an excess so we make sure that discarded tokens are also treated correctly. + await user1.CreateAccessToken(); + + Assert.NotEmpty(manager1.accessTokens); + Assert.NotEmpty(user1.accessTokens); + + using MemoryStream jsonStream = new(); + + await JsonSerializer.SerializeAsync(jsonStream, user1, AppInfo.JSON_OPTIONS); + + jsonStream.Position = 0; // Rewind the stream so it can be read. + + UserManager manager2 = new(); + User user2 = await manager2.LoadAndAddUserJson(jsonStream, user1.GUID); + + Assert.NotEmpty(manager2.accessTokens); + Assert.NotEmpty(user2.accessTokens); + + Assert.All(manager1.accessTokens.Values, originalToken => + { + + Assert.True(manager2.TryUseAccessToken(originalToken.TokenString, out User? user, out UserAccessToken? token)); + + Assert.Equal(user2, user); + Assert.Equal(originalToken.ExpiryDate, token.ExpiryDate); + Assert.Equal(originalToken.TokenString, token.TokenString); + + }); + + Assert.Equal(manager1.accessTokens.Count, manager2.accessTokens.Count); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/UserManagerTests.cs b/PMDDesktop.Server.Users.Tests/UserManagerTests.cs new file mode 100644 index 0000000..9eddc48 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/UserManagerTests.cs @@ -0,0 +1,46 @@ +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.Users.Tests; + +public class UserManagerTests +{ + + public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new() + { + DisplayName = "Test User", + LoginHandle = "test", + PlainTextPassword = "Test123" + }; + + [Fact] + public async Task GetViaGUID() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + Guid guid = user.GUID; + + Assert.True(manager.TryGetUser(guid, out User? guidUser)); + Assert.NotNull(guidUser); + + Assert.False(manager.TryGetUser(Guid.Empty, out User? notGuidUser)); + Assert.Null(notGuidUser); + + } + + [Fact] + public async Task DefaultAdminUserIsAdmin() + { + + UserManager manager = new(); + + User? user = await manager.TryCreateDefaultAdminUser(); + Assert.NotNull(user); + + Assert.True(user.IsAdmin); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs b/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs new file mode 100644 index 0000000..f534ddc --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/UserReferenceTests.cs @@ -0,0 +1,57 @@ +using System.Text.Json; + +namespace PMDDesktop.Server.Users.Tests; + +public class UserReferenceTests +{ + + public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new() + { + DisplayName = "Test User", + LoginHandle = "test", + PlainTextPassword = "Test123" + }; + + [Fact] + public async Task CanGetReference() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + UserReference reference = new(user); + + reference.TryGetUser(manager, out User? referUser); + + Assert.NotNull(referUser); + Assert.Equal(referUser, user); + + } + + [Fact] + public async Task SerializationIsSimple() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + UserReference preReference = new(user); + + JsonElement json = JsonSerializer.SerializeToElement(preReference, AppInfo.JSON_OPTIONS); + + Assert.Equal(JsonValueKind.String, json.ValueKind); + + UserReference? postReference = JsonSerializer.Deserialize(json); + + Assert.NotNull(postReference); + + postReference.TryGetUser(manager, out User? referUser); + + Assert.NotNull(referUser); + Assert.Equal(referUser, user); + + } + +} diff --git a/PMDDesktop.Server.Users.Tests/UserTests.cs b/PMDDesktop.Server.Users.Tests/UserTests.cs new file mode 100644 index 0000000..e83ecb7 --- /dev/null +++ b/PMDDesktop.Server.Users.Tests/UserTests.cs @@ -0,0 +1,267 @@ +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.Users.Tests; + +public class UserTests +{ + + public static readonly UserCreationOptions DEFAULT_USER_OPTIONS = new() + { + DisplayName = "Test User", + LoginHandle = "test", + PlainTextPassword = "Test123" + }; + + [Fact] + public async Task PasswordVerification() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + string? defaultPassword = DEFAULT_USER_OPTIONS.PlainTextPassword; + Assert.NotNull(defaultPassword); + + Assert.False(await user.VerifyPassword("Test456")); + Assert.False(await user.VerifyPassword("")); + Assert.True(await user.VerifyPassword(defaultPassword)); + + } + + [Fact] + public async Task NullPasswordVerification() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS with + { + PlainTextPassword = null + }); + Assert.NotNull(user); + + string? defaultPassword = DEFAULT_USER_OPTIONS.PlainTextPassword; + Assert.NotNull(defaultPassword); + + Assert.False(await user.VerifyPassword(defaultPassword)); + Assert.False(await user.VerifyPassword("Test456")); + + } + + [Fact] + public async Task AccessTokenGrantsAccess() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + UserAccessToken token = await user.CreateAccessToken(); + + Assert.True(manager.TryUseAccessToken(token.TokenString, out User? outUser)); + + Assert.Equal(user, outUser); + + } + + [Fact] + public async Task LoginHandleChanges() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + string? loginHandle = DEFAULT_USER_OPTIONS.LoginHandle; + Assert.NotNull(loginHandle); + Assert.True(manager.TryGetUserByLoginHandle(loginHandle, out _)); + + string newHandle = "new-handle"; + + Assert.True(await user.TrySetLoginHandle(newHandle)); + + Assert.False(manager.TryGetUserByLoginHandle(loginHandle, out User? oldHandleUser)); + Assert.Null(oldHandleUser); + + Assert.True(manager.TryGetUserByLoginHandle(newHandle, out User? newHandleUser)); + Assert.NotNull(newHandleUser); + + } + + [Fact] + public async Task IsTokenLimitEnforced() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + for (int i = 0; i < User.ACCESS_TOKEN_LIMIT + 100; ++i) + { + await user.CreateAccessToken(); + } + + Assert.Equal(manager.accessTokens.Count, User.ACCESS_TOKEN_LIMIT); + + } + + [Fact] + public async Task AreOldestTokensRemovedOnLimit() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + for (int i = 0; i < User.ACCESS_TOKEN_LIMIT; ++i) + await user.CreateAccessToken(); + + Assert.Equal(manager.accessTokens.Count, User.ACCESS_TOKEN_LIMIT); + + List seenTokens = [.. manager.accessTokens.Values]; + + for (int i = 0; i < User.ACCESS_TOKEN_LIMIT; ++i) + await user.CreateAccessToken(); + + Assert.Equal(manager.accessTokens.Count, User.ACCESS_TOKEN_LIMIT); + + seenTokens.AddRange(manager.accessTokens.Values); + Assert.Distinct(seenTokens); + + } + + [Fact] + public async Task DeleteUserTest() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + await user.CreateAccessToken(); + + Assert.True(manager.TryGetUser(user.GUID, out _)); + + Assert.NotEmpty(manager.accessTokens); + Assert.NotEmpty(manager.guids); + Assert.NotEmpty(manager.loginHandles); + + Assert.True(user.IsAlive()); + + Assert.True(await user.TryRemoveUser()); + + Assert.False(manager.TryGetUser(user.GUID, out _)); + + Assert.Empty(manager.accessTokens); + Assert.Empty(manager.guids); + Assert.Empty(manager.loginHandles); + + Assert.False(user.IsAlive()); + + } + + [Fact] + public async Task CanChangeName() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + string originalName = user.Name; + + await user.SetName("Waffles"); + + Assert.Equal("Waffles", user.Name); + + Assert.NotEqual(originalName, user.Name); + + } + + // I'm not sure if there's a better place for these role/permission/user tests. They test way more than just the User.cs file, but they start and end there. + [Fact] + public async Task UserGetsPermission() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + UserRoleManager roleManager = manager.RoleManager; + UserRole? role = await roleManager.TryCreateRole(); + Assert.NotNull(role); + await role.SetPermission(UserPermission.MANAGE_ROLES, true); + + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + Assert.True(await user.TryAddRole(role)); + + Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES)); + + Assert.True(await user.TryRemoveRole(role)); + + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + } + + [Fact] + public async Task UserPermissionsRespectHierarchy() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + UserRoleManager roleManager = manager.RoleManager; + UserRole? givingRole = await roleManager.TryCreateRole(); + Assert.NotNull(givingRole); + await givingRole.SetPermission(UserPermission.MANAGE_ROLES, true); + await user.TryAddRole(givingRole); + + UserRole? takingRole = await roleManager.TryCreateRole(); + Assert.NotNull(takingRole); + await takingRole.SetPermission(UserPermission.MANAGE_ROLES, false); + await user.TryAddRole(takingRole); + + UserRole? nothingRole = await roleManager.TryCreateRole(); + Assert.NotNull(nothingRole); + await nothingRole.SetPermission(UserPermission.MANAGE_ROLES, null); + await user.TryAddRole(nothingRole); + + await roleManager.SetOrder([givingRole.GUID, takingRole.GUID, nothingRole.GUID]); + Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES)); + + await roleManager.SetOrder([takingRole.GUID, givingRole.GUID, nothingRole.GUID]); + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + await roleManager.SetOrder([nothingRole.GUID, givingRole.GUID, takingRole.GUID]); + Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES)); + + await roleManager.SetOrder([nothingRole.GUID, takingRole.GUID, givingRole.GUID]); + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + } + + [Fact] + public async Task AdminUserGetsPermissions() + { + + UserManager manager = new(); + User? user = await manager.TryCreateUser(DEFAULT_USER_OPTIONS); + Assert.NotNull(user); + + // This test assumes that MANAGE_ROLE has AlwaysForAdmin enabled. + Assert.True(UserPermission.MANAGE_ROLES.AlwaysForAdmin); + + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + await user.SetAdmin(true); + + Assert.True(user.HasPermission(UserPermission.MANAGE_ROLES)); + + await user.SetAdmin(false); + + Assert.False(user.HasPermission(UserPermission.MANAGE_ROLES)); + + } + +} diff --git a/PMDDesktop.Server.Users/IUserHierarchyComparable.cs b/PMDDesktop.Server.Users/IUserHierarchyComparable.cs new file mode 100644 index 0000000..c600a1b --- /dev/null +++ b/PMDDesktop.Server.Users/IUserHierarchyComparable.cs @@ -0,0 +1,18 @@ +namespace PMDDesktop.Server.Users; + +/// +/// Interface to compare if a user has access to certain data. +/// Lower numeric-values are higher permissions; a user must have a higher permission to edit other users' data. +/// +public interface IUserHierarchyComparable +{ + + /// + /// Returns a number indicating the highest place in the hierarchy this user has. + /// 0 should be the highest value obtainable by a normal user. -1 is obtainable to admins only. + /// Lower numeric values are higher permissions; this should usually be calculated based on role order. + /// + /// Number indicating the highest place in the hierarchy this user has. + int GetHierarchyPosition(); + +} diff --git a/PMDDesktop.Server.Users/IUserIndexable.cs b/PMDDesktop.Server.Users/IUserIndexable.cs new file mode 100644 index 0000000..ad4f65c --- /dev/null +++ b/PMDDesktop.Server.Users/IUserIndexable.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; + +namespace PMDDesktop.Server.Users; + +/// +/// Interface for objects that can fetch a from a . +/// +public interface IUserIndexable +{ + + /// + /// Try to get a by their . Will return false and will be null if not found. + /// + /// The of the you're looking for. + /// The found , if any was found. Will be null if the return was false. + /// True if the is found, and will not be null. + bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user); + +} diff --git a/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj new file mode 100644 index 0000000..70d22b0 --- /dev/null +++ b/PMDDesktop.Server.Users/PMDDesktop.Server.Users.csproj @@ -0,0 +1,19 @@ + + + + Library + net10.0 + enable + enable + true + + + + + + + + + + + diff --git a/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs b/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs new file mode 100644 index 0000000..a487434 --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/IRoleIndexable.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; + +namespace PMDDesktop.Server.Users.Roles; + +/// +/// Interface for objects that can fetch a from a . +/// +public interface IRoleIndexable +{ + + /// + /// Try to get a by its . Will return false and will be null if not found. + /// + /// The of the you're looking for. + /// The found , if any was found. Will be null if the return was false. + /// True if the is found, and will not be null. + bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role); + +} diff --git a/PMDDesktop.Server.Users/Roles/RoleReference.cs b/PMDDesktop.Server.Users/Roles/RoleReference.cs new file mode 100644 index 0000000..677d98c --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/RoleReference.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users.Roles; + +[JsonConverter(typeof(RoleReferenceConverter))] +public sealed class RoleReference +{ + + /// + /// Stored corresponding to . + /// + public Guid GUID { get; private set; } + + /// + /// Create a new pointing to 's . + /// This allows you to find the again after serialization and deserialization. + /// + /// The you'd like to keep a reference to via . + public RoleReference(UserRole initalValue) + { + + GUID = initalValue.GUID; + + } + + /// + /// Create a new pointing exactly to + /// The existance of any with this is not checked until is called. + /// + /// The you'd like to point to. + internal RoleReference(Guid directGuid) + { + + GUID = directGuid; + + } + + /// + /// Try to get a from its . + /// + /// The to use to find . + /// The result of the search. Will be null if false is returned, and contain a if true. + /// True if the was found and has a value, otherwise false. + public bool TryGetRole(IRoleIndexable indexable, [NotNullWhen(true)] out UserRole? role) + { + + return indexable.TryGetRole(GUID, out role); + + } + +} diff --git a/PMDDesktop.Server.Users/Roles/RoleReferenceConverter.cs b/PMDDesktop.Server.Users/Roles/RoleReferenceConverter.cs new file mode 100644 index 0000000..f36b0a5 --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/RoleReferenceConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users.Roles; + + +internal class RoleReferenceConverter : JsonConverter +{ + public override RoleReference Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"{Type} was expecting a string token to deserialize."); + + string guidString = reader.GetString() ?? throw new JsonException($"We read a null string while trying to deserialize {Type}."); + + Guid guid = Guid.Parse(guidString); + + return new RoleReference(guid); + + } + + public override void Write(Utf8JsonWriter writer, RoleReference value, JsonSerializerOptions options) + { + + writer.WriteStringValue(value.GUID.ToString()); + + } +} diff --git a/PMDDesktop.Server.Users/Roles/UserPermission.cs b/PMDDesktop.Server.Users/Roles/UserPermission.cs new file mode 100644 index 0000000..4469f61 --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/UserPermission.cs @@ -0,0 +1,113 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace PMDDesktop.Server.Users.Roles; + +public sealed class UserPermission +{ + + static UserPermission() + { + + FieldInfo[] fields = typeof(UserPermission).GetFields(BindingFlags.Static | BindingFlags.Public); + + IEnumerable permissionFields = fields.Where(field => field.FieldType.IsAssignableTo(typeof(UserPermission))); + + IEnumerable permissionObjects = permissionFields.Select(field => + (UserPermission?)field.GetValue(ALL) + ?? throw new NullReferenceException($"Unable to convert {field} to {nameof(UserPermission)}")); + + ALL = ImmutableArray.Create([.. permissionObjects]); + + } + + public static bool TryGetPermissionByDataName(string dataName, [NotNullWhen(true)] out UserPermission? permission) + { + + foreach (UserPermission checking in ALL) + { + + if (checking.DataName != dataName) + continue; + + permission = checking; + return true; + + } + + permission = null; + return false; + + } + + /// + /// Array containing all objects. (Should not change after initial creation.) + /// + public static readonly ImmutableArray ALL; + + public static readonly UserPermission MANAGE_USERS = new() + { + DataName = "manage-users", + FriendlyName = "Manage Users", + FriendlyDescription = "Allow creation, modification, and deletion of user accounts.", + AlwaysForAdmin = true, + EnabledForDefault = false + }; + + public static readonly UserPermission MANAGE_ROLES = new() + { + DataName = "manage-roles", + FriendlyName = "Manage Roles", + FriendlyDescription = "Allow creation, modification, and removal of roles and their permissions.", + AlwaysForAdmin = true, + EnabledForDefault = false + }; + + /// + /// Name that is stored and compared to track permission in a . + /// + public required string DataName { get; init; } + + /// + /// User-facing name that is displayed to users when viewing permissions. + /// + public required string FriendlyName { get; init; } + + /// + /// User-facing description for what a permission does. + /// + public required string FriendlyDescription { get; init; } + + /// + /// Weather to always grant this permission for admin users, to prevent losing ability to regain the permission. + /// + public required bool AlwaysForAdmin { get; init; } + + /// + /// Weather to enable this permission for the default role when first created. (Can still be disabled for that role afterwards.) + /// + public required bool EnabledForDefault { get; init; } + + private UserPermission() { } + + public override int GetHashCode() + { + return HashCode.Combine(DataName); + } + + public override bool Equals(object? obj) + { + + if (obj is UserPermission permission) + return permission.DataName == DataName; + + return base.Equals(obj); + } + + public override string ToString() + { + return $"{nameof(UserPermission)}:{DataName}"; + } + +} diff --git a/PMDDesktop.Server.Users/Roles/UserRole.cs b/PMDDesktop.Server.Users/Roles/UserRole.cs new file mode 100644 index 0000000..e78df7c --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/UserRole.cs @@ -0,0 +1,277 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users.Roles; + +[JsonConverter(typeof(UserRoleConverter))] +public sealed class UserRole : IUserHierarchyComparable +{ + + internal UserRole() { } + + /// + /// A containing granted/revoked s. + /// An entry is true if this grants that , and false if it revokes the . + /// A non-existant entry states that the is unaffected. + /// + internal Dictionary permissionSettings = []; + + /// + /// A string representing a 's name. + /// + public string Name { get; internal set; } = "Unnamed Role"; + + /// + /// A unique for this . + /// This should be used for long-term references to a specific . + /// + public Guid GUID { get; internal set; } = Guid.NewGuid(); + + /// + /// Weather this is the "default role." + /// + /// + /// Currently just checks if matches . + /// + public bool IsDefaultRole { get => GUID == Guid.Empty; } + + /// + /// Attached that controls this . + /// + public UserRoleManager? Manager { get; private set; } + + /// + /// Is/Was on the last used or currently active . + /// + public bool? WritingEnabled { get; private set; } + + /// + /// Set the of this . + /// + /// The new name of this . + /// Completes the task once the has saved. + public async Task SetName(string newName) + { + + Name = newName; + + await WriteNewData(); + + } + + /// + /// Set the state of in this . + /// Pass true to permit , false to revoke it, and null to not affect it. + /// + /// The to modify. + /// True if permission should be granted, False is permission should be revoked, null if permission should be unaffected. + /// Completes the task once the has saved. + public async Task SetPermission(UserPermission permission, bool? preference) + { + + if (preference is bool boolPreference) + permissionSettings[permission] = boolPreference; + else + permissionSettings.Remove(permission); + + if (WritingEnabled is true) + await WriteNewData(); + + } + + /// + /// Get the state of in this . + /// + /// The to check. + /// True if permission is granted, False is permission is revoked, null if permission is unaffected. + public bool? GetPermission(UserPermission permission) + { + + if (permissionSettings.TryGetValue(permission, out bool value)) + return value; + + return null; + + } + + /// + /// Create a new with and the s marked as . + /// + /// A new configured to be the default role. + internal static UserRole CreateDefaultRole() + { + + UserRole role = new() + { + GUID = Guid.Empty, + Name = "Default (Everyone)" + }; + + foreach (UserPermission permission in UserPermission.ALL.Where(perm => perm.EnabledForDefault)) + role.permissionSettings.Add(permission, true); + + return role; + + } + + /// + /// Attach to a to provide this 's data to the . + /// + /// The to attach to. + /// Throws if already attached to a . + internal void AttachToManager(UserRoleManager newManager) + { + + if (Manager != null) + throw new InvalidOperationException($"This existing value of {nameof(Manager)} is not null. Cannot attach while already attached. Does this call to {nameof(AttachToManager)} need to be skipped, or does {nameof(DetachFromManager)} need to be called first?"); + + Manager = newManager; + WritingEnabled = Manager.WritingEnabled; + + newManager.guids.Add(GUID, this); + + if (!IsDefaultRole) + newManager.roleOrder = [.. newManager.roleOrder, GUID]; + + } + + /// + /// Detach from the attached to remove this 's data from the attached . + /// + /// Throws if not currently attached to a . + /// + /// This doesn't really need to be called on server shutdown. It is called internally when deleting a . + /// + internal void DetachFromManager() + { + + if (Manager == null) + throw new NullReferenceException($"{nameof(Manager)} is null. Cannot detach from nothing. Does this call to {nameof(DetachFromManager)} need to be skipped?"); + + if (Manager.guids[GUID] != this) + throw new InvalidOperationException($"{this} was not inside {Manager.guids} with key {GUID}."); + + Manager.guids.Remove(GUID); + + if (!IsDefaultRole) + Manager.roleOrder = [.. Manager.roleOrder.Where(guid => guid != GUID)]; + + Manager = null; + + } + + /// + /// Gets the primary JSON that contains this 's data, located inside the folder. + /// + /// A path pointing to where the 's json data is/should be stored. + internal string GetRoleJSONPath() + { + + return Path.Combine(GetRoleFolder(), $"{GUID}.json"); + + } + + internal static string GetRoleFolder() + { + + return Path.Combine(AppContext.BaseDirectory, "roles"); + + } + + /// + /// Saves all data relating to this object. + /// + /// Task completes once data is written. + /// If the or if is null. + [ExcludeFromCodeCoverage] + internal async Task WriteNewData() + { + + if (Manager is null) // ????? This state doesn't make any sense. + throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(Manager)} is null!"); + + if (WritingEnabled is null) + throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(WritingEnabled)} is null!"); + + if (WritingEnabled != true) + return; + + Directory.CreateDirectory(GetRoleFolder()); + using FileStream jsonFile = File.Create(GetRoleJSONPath()); + + await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS); + + } + + /// + /// Deletes the folder and all *explicitly written* data inside it. + /// + /// + /// + /// This function does not recursive delete. Instead it has a list of items to delete. This approach is taken to minimize data loss in the event that something goes wrong. + /// + [ExcludeFromCodeCoverage] + private async Task DeleteRoleFolder() + { + + if (WritingEnabled is null) + throw new InvalidOperationException($"{this} shouldn't be deleting its data from a file while {nameof(WritingEnabled)} is null!"); + + if (WritingEnabled != true) + return; + + File.Delete(GetRoleJSONPath()); + + } + + /// + /// Try to remove this from its . + /// If the assigned has , then this function will also attempt to delete the role's file from the filesystem, so that it will not be restored in future sessions. + /// + /// + /// If is enabled, this function may still throw if an IO-related error occurs. + /// + public async Task TryRemoveRole() + { + + if (Manager is null) + return false; + + if (IsDefaultRole) + return false; // Default role cannot be removed manually. + + UserRoleManager oldManager = Manager; + + DetachFromManager(); + + await DeleteRoleFolder(); + + await oldManager.WriteNewRoleOrder(); + + return true; + + } + + /// + /// Returns the current index that this role is slotted in the role order. + /// Starts at 0, and ends at the current role count minus 1. + /// Numbers closer to 0 are first and have a higher priority in permission evaluation. + /// + /// The index this role is located at in the role order. + public int GetCurrentOrderIndex() + { + + if (Manager is null) + throw new InvalidOperationException($"{this} can't check its order index when {nameof(Manager)} is null!"); + + if (IsDefaultRole) // We can just assume this is the last role. It's faster + it's not even in the role order. + return Manager.roleOrder.Length; + + return Manager.roleOrder.IndexOf(GUID); + + } + + public int GetHierarchyPosition() => GetCurrentOrderIndex(); + +} diff --git a/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs b/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs new file mode 100644 index 0000000..76ad4ab --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/UserRoleConverter.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users.Roles; + +public class UserRoleConverter : JsonConverter +{ + + /// + /// The property that the role's name will be stored in. + /// + internal static readonly string NAME_PROPERTY = "Name"; + + /// + /// The property that the role's permissions will be stored in. + /// + internal static readonly string PERMISSIONS_PROPERTY = "Permissions"; + + public override UserRole? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + + using JsonDocument document = JsonDocument.ParseValue(ref reader); + + List propertyList = [.. document.RootElement.EnumerateObject().Select(element => element.Name)]; + + UserRole role = new() + { + Name = QuickReadString(document, propertyList, NAME_PROPERTY) + }; + + propertyList.Remove(PERMISSIONS_PROPERTY); + role.permissionSettings = ReadPermissions(document); + + if (propertyList.Count > 0) + throw new JsonException($"The {nameof(UserRole)} json had left-over properties after parsing, such as {propertyList[0]}. These unused properties cannot be accounted for, and may result in data loss. Is it from a newer version?"); + + return role; + + } + + public override void Write(Utf8JsonWriter writer, UserRole value, JsonSerializerOptions options) + { + + writer.WriteStartObject(); + + // Write name + writer.WriteString(NAME_PROPERTY, value.Name); + + // Write permissions + writer.WriteStartObject(PERMISSIONS_PROPERTY); + foreach (UserPermission permission in UserPermission.ALL) + QuickWritePermission(writer, permission, value); + writer.WriteEndObject(); + + writer.WriteEndObject(); + + } + + /// + /// Write true/false/null for 's to . + /// + /// The to write this boolean/null to; expecting to be in an open object. + /// The to write about. + /// The coming from . + private static void QuickWritePermission(Utf8JsonWriter writer, UserPermission permission, UserRole value) + { + + bool? result = value.GetPermission(permission); + + if (result is bool boolResult) + writer.WriteBoolean(permission.DataName, boolResult); + else + writer.WriteNull(permission.DataName); + + } + + /// + /// Quickly read (string) from and remove from . + /// + /// The to read from. + /// The to remove from. + /// The to read from and to remove from . + /// Returns the read string. + /// If name is null. + private static string QuickReadString(JsonDocument document, List propertyList, string name) + { + + propertyList.Remove(name); + + return document.RootElement.GetProperty(name).GetString() + ?? throw new JsonException($"{name} should not be null in {document}"); + + } + + /// + /// Read the permissions from and turn them into a . + /// + /// The to read from. + /// A containing modified permissions. + /// If a value wasn't true, false, or null. + private static Dictionary ReadPermissions(JsonDocument document) + { + + Dictionary permissionSettings = []; + JsonElement jsonObj = document.RootElement.GetProperty(PERMISSIONS_PROPERTY); + + foreach (JsonProperty property in jsonObj.EnumerateObject()) + { + + string dataName = property.Name; + + // We get this first, because if we can't find a permission even for a "null" permission, there's a chance we could be causing data loss in a different role. + UserPermission permission = UserPermission.ALL.First(perm => perm.DataName == dataName); + + bool? value = property.Value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => throw new JsonException($"Unsupported value kind for {dataName}: {property.Value.ValueKind}") + }; + + if (value is bool boolValue) + permissionSettings.Add(permission, boolValue); + + } + + return permissionSettings; + + } + +} diff --git a/PMDDesktop.Server.Users/Roles/UserRoleManager.cs b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs new file mode 100644 index 0000000..75dfa14 --- /dev/null +++ b/PMDDesktop.Server.Users/Roles/UserRoleManager.cs @@ -0,0 +1,260 @@ +using System.Collections; +using System.Data; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace PMDDesktop.Server.Users.Roles; + +/// +/// Manages s. Allows the creation of new objects and handles the saving/loading of them, as well as maintaining the order of roles. +/// +public sealed class UserRoleManager : IRoleIndexable, IEnumerable +{ + + /// + /// Creates a new that's paied with . + /// This will be tightly linked to the given + /// + /// The to pair with. + internal UserRoleManager(UserManager userManager) + { + + Manager = userManager; + + UserRole.CreateDefaultRole().AttachToManager(this); + + } + + /// + /// The that's paired with this . + /// + public UserManager Manager { get; private init; } + + /// + /// Does this actually write to files? + /// If this is false, file writes to the filesystem/disk will be skipped. + /// This property checks to get its value. + /// + /// + /// This is property as "false" is very useful in unit testing. Should probably be "true" during runtime. + /// + public bool WritingEnabled { get => Manager.WritingEnabled; } + + /// + /// This is a dictionary with all the s mapped to their . + /// + /// + /// This dictionary's entries are not managed by the , but instead by the s as they are attached/detached. Do not let write to this dictionary. + /// + internal Dictionary guids = []; + + /// + /// Default applied to all users when checking for s. + /// Cannot be added or removed from s. + /// + internal UserRole DefaultRole { get => guids[Guid.Empty]; } + + /// + /// Order to check when evaluating s based on s. + /// Should never contain (). + /// + internal Guid[] roleOrder = []; + + /// + /// Loads from the "roles" directory and "roleOrder.json" file to retrieve saved role data. + /// + /// Task completes when loading is finished. + internal async Task LoadFromFiles() + { + + await LoadAllRoleData(); + + await LoadRoleOrder(); + + } + + /// + /// This manages creating objects by loading their data from the "roles" folder. + /// + [ExcludeFromCodeCoverage] + internal async Task LoadAllRoleData() + { + + string roleRootDirPath = Path.Combine(AppContext.BaseDirectory, "roles"); + + if (!Directory.Exists(roleRootDirPath)) + Directory.CreateDirectory(roleRootDirPath); + + foreach (string filePath in Directory.EnumerateFiles(roleRootDirPath)) + { + + string guidParsable = Path.GetFileNameWithoutExtension(filePath); + + if (!Guid.TryParse(guidParsable, out Guid loadedGUID)) + throw new Exception($"Couldn't parse {guidParsable} as a GUID at {filePath}"); + + using FileStream readStream = File.OpenRead(filePath); + + await LoadAndAddRoleJson(readStream, loadedGUID); + + } + + } + + /// + /// Internal function for loading a JSON stream and adding the resulting to this . + /// + /// A JSON stream with data inside. + /// + /// + /// This can be safely unit tested, as it takes in a stream instead of reading from a file. + /// + internal async Task LoadAndAddRoleJson(Stream stream, Guid guid) + { + + UserRole deserialized = JsonSerializer.Deserialize(stream, AppInfo.JSON_OPTIONS) + ?? throw new Exception($"Deserialized user role data from {stream} was null."); + + deserialized.GUID = guid; // GUID will be randomized by default, we need to load the previous GUID. + + deserialized.AttachToManager(this); + + return deserialized; + + } + + /// + /// Returns the path to the "roleOrder.json" file, intended to be used to save/load the order of roles. + /// + /// A path poiting to the "roleOrder.json" file. + internal static string GetOrderJSONPath() + { + + return Path.Combine(AppContext.BaseDirectory, "roleOrder.json"); + + } + + /// + /// Loads the role order from roleOrder.json. Skips if the file doesn't exist; otherwise, the file is also checked for validity and may throw. + /// + /// Task completes when role order is set or doesn't load. + /// + /// Calls to read the stream. + /// + [ExcludeFromCodeCoverage] + private async Task LoadRoleOrder() + { + + string path = GetOrderJSONPath(); + + if (!File.Exists(path)) + return; // No biggie! Just assume default order. + + using Stream stream = File.OpenRead(path); + + await LoadRoleOrder(stream); + + } + + /// + /// Loads the role order from the provided stream in Guid[] JSON format. JSON and order is checked for validity and may throw. + /// + /// Task completes when role order is set. + internal async Task LoadRoleOrder(Stream stream) + { + + Guid[] order = await JsonSerializer.DeserializeAsync(stream, AppInfo.JSON_OPTIONS) + ?? throw new Exception($"Deserialized user role order from {stream} was null."); + + await SetOrder(order); + + } + + /// + /// Attempt to set the order of roles by providing the s of every in this . + /// s should be provided in the order of most important (highest) to least important (lowest). s that are higher (provided first) will take priority over lower roles when they have clashing s. + /// If provided, () will be ignored. + /// Will fail if duplicated GUIDs are provided, or GUIDs that do not correspond to s in this . + /// + /// + /// Task completes once order is set and changes are saved. + public async Task SetOrder(IEnumerable guidOrder) + { + + Guid[] orderArray = [.. guidOrder.Where(guid => guid != Guid.Empty)]; + + // Each role's GUID should be provided exactly once. + foreach (UserRole role in guids.Values.Where(role => !role.IsDefaultRole)) + if (orderArray.Count(role.GUID) != 1) + throw new ArgumentException($"{role}'s GUID ({role.GUID}) was listed {orderArray.Count(role.GUID)} times in the provided order. There needs to be exactly 1 GUID for each role."); + + // All GUIDs should match a role. + foreach (Guid guid in orderArray) + if (!guids.ContainsKey(guid)) + throw new ArgumentException($"There is no matching {nameof(UserRole)} in {this} that has GUID {guid}."); + + roleOrder = orderArray; + + if (WritingEnabled) + await WriteNewRoleOrder(); + + return; + + } + + /// + /// If is true, will write the current to "roleOrder.json". + /// + /// + public async Task WriteNewRoleOrder() + { + + if (!WritingEnabled) + return; + + using FileStream jsonFile = File.Create(GetOrderJSONPath()); + + await JsonSerializer.SerializeAsync(jsonFile, roleOrder, AppInfo.JSON_OPTIONS); + + } + + public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) + { + return guids.TryGetValue(GUID, out role); + } + + /// + /// Try to create a new , will return null if it could not be done. It will have default settings and no permissions set. + /// + /// The created , or null if it failed. + public async Task TryCreateRole() + { + + UserRole role = new(); + + role.AttachToManager(this); + + await role.WriteNewData(); + + await WriteNewRoleOrder(); + + return role; + + } + + public IEnumerator GetEnumerator() + { + + foreach (Guid guid in roleOrder) + yield return guids[guid]; + + yield return DefaultRole; + + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + +} diff --git a/PMDDesktop.Server.Users/User.cs b/PMDDesktop.Server.Users/User.cs new file mode 100644 index 0000000..917a83e --- /dev/null +++ b/PMDDesktop.Server.Users/User.cs @@ -0,0 +1,687 @@ +using Microsoft.AspNetCore.Identity; +using PMDDesktop.Server.Users.Roles; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users; + +[JsonConverter(typeof(UserConverter))] +public sealed class User : IUserHierarchyComparable +{ + + /// + /// to use to hash passwords. + /// + private static PasswordHasher PASSWORD_HASHER = new(); + + /// + /// The name of the file that data should be serialized to. + /// + public static readonly string USER_FILE_NAME = "user.json"; + + /// + /// A list of items to delete when deleting a . + /// + private static string[] DELETE_USER_FOLDER_ITEMS = [USER_FILE_NAME, "picture.jpg", "picture.png"]; + + /// + /// Limit of s assigned to one . + /// + /// + /// This is supposed to help combat a senario where a user creates millions of access tokens to intentionally fill up a hard drive or system memory. + /// Downside is that it prevents an honest user from having a large number of legitimate connections. + /// + public static readonly int ACCESS_TOKEN_LIMIT = 20; + + [JsonConstructor] + internal User() + { + + } + + /// + /// A string representing a 's name. + /// + [JsonInclude] + public string Name { get; internal set; } = string.Empty; + + /// + /// A string to use for logging in. Should only ever contain [a-z], [0-9] and (-) + /// Does not neccessarily match in most senarios. + /// Should be unique. + /// + [JsonInclude] + public string? LoginHandle { get; internal set; } + + /// + /// Hashed password used to log in. + /// In the event that it is null, access to this account cannot be granted via password. + /// + [JsonInclude] + internal string? HashedPassword { get; set; } + + /// + /// A unique for this . Stays consistant even when the 's handle changes. + /// This should be used for long-term references to a specific . + /// + [JsonIgnore] + public Guid GUID { get; internal set; } = Guid.NewGuid(); + + /// + /// Attached that controls this . + /// + [JsonIgnore] + public UserManager? Manager { get; private set; } + + /// + /// Is/Was on the last used or currently active . + /// + public bool? WritingEnabled { get => Manager is not null ? Manager.WritingEnabled : field; private set; } + + /// + /// Specifies if this user is affected by . + /// + public bool IsAdmin { get; internal set; } + + /// + /// List of s currently belonging to this . + /// May include expired s. + /// + internal List accessTokens = []; + + /// + /// Collection of s assigned to this user by their s. Order does not matter, use for order instead. + /// Also, is not included in this. It is implied to always be assigned. + /// + internal HashSet roles = []; + + #region Name + + /// + /// Set the of this . + /// + /// The new name of the . + /// Completes task once changes are saved. + public async Task SetName(string newName) + { + + Name = newName; + + if (Manager is not null) + await WriteNewData(); + + } + + #endregion + + #region Login Handle & Password + + /// + /// Salt and hash the user's password, then store it in HashedPassword. + /// + /// The plaintext password to be hashed. + /// Completes task after saving completes. + public async Task SetPassword(string? newPassword) + { + + if (newPassword is string passwordText) + HashedPassword = PASSWORD_HASHER.HashPassword(this, passwordText); + else + HashedPassword = null; + + if (Manager is not null) + await WriteNewData(); + + } + + /// + /// Try to set to the input. Will fail if it is not unique. + /// + /// The string to change the new handle to. + /// True if the handle was changed and saved. + public async Task TrySetLoginHandle(string? newHandle) + { + + // Handle is not the same. + if (newHandle == LoginHandle) + return false; + + // Handle is free. + if (newHandle is not null && Manager is not null && Manager.loginHandles.ContainsKey(newHandle)) + return false; + + DetachLoginHandle(); + + LoginHandle = newHandle; + + AttachLoginHandle(); + + if (Manager is not null) + await WriteNewData(); + + return true; + + } + + /// + /// Add the current to . + /// + private void AttachLoginHandle() + { + + if (LoginHandle is null) + return; + + Manager?.loginHandles.Add(LoginHandle, this); + + } + + /// + /// Remove the current from . + /// + private void DetachLoginHandle() + { + + if (LoginHandle is null) + return; + + Manager?.loginHandles.Remove(LoginHandle); + + } + + /// + /// Check that hashes to the same password stored in the user. + /// May also rehash the password if evidence is shown that it needs a rehash. + /// + /// The password input by the user. + /// True if the password matched. + public async Task VerifyPassword(string plainText) + { + + if (HashedPassword is null) + { + + return false; + + } + + PasswordVerificationResult result = PASSWORD_HASHER.VerifyHashedPassword(this, HashedPassword, plainText); + + if (result == PasswordVerificationResult.SuccessRehashNeeded) + { + + await SetPassword(plainText); + + } + + return result is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded; + + } + + public bool HasPassword() + { + + return HashedPassword is not null; + + } + + #endregion + + #region Saving + + /// + /// Return the filesystem folder that should contain all of this user's data. + /// + /// A path pointing to the user's folder. + /// This path is created using the user's GUID. + internal string GetUserFolder() + { + + return Path.Combine(AppContext.BaseDirectory, "users", GUID.ToString()); + + } + + /// + /// Gets the primary JSON that contains this user's data, located inside the user folder. + /// + /// A path pointing to where the user's json data is/should be stored. + public string GetUserJSONPath() + { + + return Path.Combine(GetUserFolder(), USER_FILE_NAME); + + } + + /// + /// Saves all data relating to this object. + /// + /// Task completes once data is written. + /// If the or if is null. + [ExcludeFromCodeCoverage] + internal async Task WriteNewData() + { + + if (Manager is null) // ????? This state doesn't make any sense. + throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(Manager)} is null!"); + + if (WritingEnabled is null) + throw new InvalidOperationException($"{this} shouldn't be writing its data to a file while {nameof(WritingEnabled)} is null!"); + + foreach (UserAccessToken token in accessTokens) + if (DateTime.UtcNow >= token.ExpiryDate) + await RevokeAccessToken(token, false); + + if (WritingEnabled != true) + return; + + Directory.CreateDirectory(GetUserFolder()); + using FileStream jsonFile = File.Create(GetUserJSONPath()); + + await JsonSerializer.SerializeAsync(jsonFile, this, AppInfo.JSON_OPTIONS); + + } + + /// + /// Deletes the user folder and all *explicitly written* data inside it. + /// + /// Completes task once folder is deleted... or after it fails to delete due to having unmanaged filesystem entries. + /// + /// This function does not recursive delete. Instead it has a list of items to delete. This approach is taken to minimize data loss in the event that something goes wrong. + /// + [ExcludeFromCodeCoverage] + private async Task DeleteUserFolder() + { + + if (WritingEnabled is null) + throw new InvalidOperationException($"{this} shouldn't be deleting its data from the filesystem while {nameof(WritingEnabled)} is null!"); + + if (WritingEnabled != true) + return; + + foreach (string item in DELETE_USER_FOLDER_ITEMS) + { + + string path = Path.Join(GetUserFolder(), item); + + if (File.Exists(path)) + File.Delete(path); + + } + + if (Directory.GetFileSystemEntries(GetUserFolder()).Length == 0) + Directory.Delete(GetUserFolder(), false); + else + Console.WriteLine($"CANNOT DELETE ALL OF {this}'s user folder!!!"); + + } + + #endregion + + #region Generic Object Overrides + + public override bool Equals(object? obj) + { + + if (obj is User user) + { + + return user.GUID == GUID; + + } + + return false; + + } + + public override int GetHashCode() + { + return HashCode.Combine(GUID); + } + + #endregion + + #region Access Tokens + + /// + /// Create a new for this user. + /// + /// Returns the new . + /// + /// Will remove existing access tokens if is exceeded. + /// + public async Task CreateAccessToken() + { + + while (accessTokens.Count >= ACCESS_TOKEN_LIMIT) + await TryRevokeOldestAccessToken(false); + + UserAccessToken token = UserAccessToken.CreateNewToken(this); + + accessTokens.Add(token); + + AttachAccessToken(token); + + if (Manager is not null) + await WriteNewData(); + + return token; + + } + + /// + /// Attaches to the attached . + /// + /// The to attach to . + /// + /// Attaching will be skipped if is null, so this is safe to call when detached. + /// + private void AttachAccessToken(UserAccessToken token) + { + + // if (accessTokens.Any(existing => existing.TokenString == token.TokenString)) + // throw new Exception($"The token: {token.TokenString} is already in {this}'s {nameof(accessTokens)}."); + + Manager?.accessTokens.Add(token.TokenString, token); + + } + + /// + /// Revokes , removes it from the , then (optionally) saves. + /// + /// The to revoke. + /// Should save afterwards? Always skipped if is null. + /// Completes task when saving completes. + internal async Task RevokeAccessToken(UserAccessToken token, bool writeAfterwards) + { + + accessTokens.Remove(token); + + DetachAccessToken(token); + + if (Manager is not null && writeAfterwards) + await WriteNewData(); + + } + + /// + /// Revokes , removes it from the , then saves. + /// + /// The to revoke. + /// Completes task when saving completes. + public async Task RevokeAccessToken(UserAccessToken token) + { + + await RevokeAccessToken(token, true); + + } + + /// + /// Detaches from the attached . + /// + /// The to detach to . + /// + /// Detaching will be skipped if is null, so this is safe to call when detached. + /// + private void DetachAccessToken(UserAccessToken token) + { + + Manager?.accessTokens.Remove(token.TokenString); + + } + + /// + /// Attempt to revoke the oldest managed by this . Can optionally save afterwards. + /// + /// Should save afterwards? + /// Returns true if an access token was revoked. + private async Task TryRevokeOldestAccessToken(bool writeAfterwards = true) + { + + UserAccessToken? oldest = null; + + foreach (UserAccessToken token in accessTokens) + { + + oldest ??= token; + + if (oldest.ExpiryDate > token.ExpiryDate) + oldest = token; + + } + + if (oldest is null) + return false; + + await RevokeAccessToken(oldest, writeAfterwards); + return true; + + } + + #endregion + + #region Manager Attaching + + /// + /// Attach to a to provide this 's data to the . + /// + /// The to attach to. + /// Throws if already attached to a . + internal void AttachToManager(UserManager manager) + { + + if (Manager != null) + throw new InvalidOperationException($"This existing value of {nameof(Manager)} is not null. Cannot attach while already attached. Does this call to {nameof(AttachToManager)} need to be skipped, or does {nameof(DetachFromManager)} need to be called first?"); + + Manager = manager; + + Manager.guids.Add(GUID, this); + + AttachLoginHandle(); + + foreach (UserAccessToken token in accessTokens) + AttachAccessToken(token); + + } + + /// + /// Detach from the attached to remove this 's data from the attached . + /// + /// Throws if not currently attached to a . + /// + /// This doesn't really need to be called on server shutdown. It is called internally when deleting a user. + /// + internal void DetachFromManager() + { + + if (Manager == null) + throw new NullReferenceException($"{nameof(Manager)} is null. Cannot detach from nothing. Does this call to {nameof(DetachFromManager)} need to be skipped?"); + + if (Manager.guids[GUID] != this) + throw new InvalidOperationException($"{this} was not inside {Manager.guids} with key {GUID}."); + + Manager.guids.Remove(GUID); + + DetachLoginHandle(); + + foreach (UserAccessToken token in accessTokens) + DetachAccessToken(token); + + WritingEnabled = Manager.WritingEnabled; + Manager = null; + + } + + public bool IsAlive() => Manager is not null; + + #endregion + + #region User Deletion + + /// + /// Try to remove this from its . + /// If the assigned has , then this function will also attempt to delete the user's folder from the filesystem, so that it will not be restored in future sessions. + /// + /// + /// If is enabled, this function may still throw if an IO-related error occurs. + /// + public async Task TryRemoveUser() + { + + if (Manager is null) + return false; + + DetachFromManager(); + + await DeleteUserFolder(); + + return true; + + } + + #endregion + + #region Role/Permission Management + + /// + /// Try to add a to this . Allows the to be influenced by the 's s. + /// + /// The to add to the . + /// Returns true if it was added successfully. + /// + /// Primarily fails when the is already on the . + /// This also saves the user. + /// + public async Task TryAddRole(UserRole role) + { + + if (!roles.Add(role.GUID)) + return false; + + await WriteNewData(); + + return true; + + } + + /// + /// Try to remove a from this . Stops the from being influenced by the 's s. + /// + /// The to remove from the . + /// Returns true if it was removed successfully. + /// + /// Primarily fails when the does not have the . + /// This also saves the user. + /// + public async Task TryRemoveRole(UserRole role) + { + + if (!roles.Remove(role.GUID)) + return false; + + await WriteNewData(); + + return true; + + } + + /// + /// Check if the has assigned to it. + /// + /// The to check for. + /// True if this user is assigned this role. + public bool HasRole(UserRole role) + { + + return roles.Contains(role.GUID); + + } + + /// + /// Does this , looking at the assigned s with respect to 's role order, have ? + /// + /// + /// Returns true if this user has the . + /// Throws if not currently attached to a . + public bool HasPermission(UserPermission permission) + { + + if (Manager is null) + throw new NullReferenceException($"Can't check if {this} has a permission if {nameof(Manager)} is null!"); + + // Is this an admin permission? + // Checked after Manager because we shouldn't be checking for permissions on a detached user anyways. + if (permission.AlwaysForAdmin && IsAdmin) + return true; + + foreach (UserRole role in Manager.RoleManager) + { + + if (!HasRole(role)) + continue; + + bool? newState = role.GetPermission(permission); + + if (newState is bool final) + return final; + + } + + return false; + + } + + /// + /// Set whether this is an admin and is affected by . + /// + /// Whether or not this should be an admin. + /// Completes task once changes are saved. + public async Task SetAdmin(bool newState) + { + + IsAdmin = newState; + + if (Manager is not null) + await WriteNewData(); + + } + + /// + /// Returns true if this could edit based on , given that it has the correct permissions. + /// Before you let an edit happen, ensure the user has both the correct permission and a higher hierarchy. + /// + /// True if this user is higher than . + public bool HigherThan(IUserHierarchyComparable target) + { + + return GetHierarchyPosition() < target.GetHierarchyPosition(); + + } + + public int GetHierarchyPosition() + { + + if (Manager is null) + throw new NullReferenceException($"Can't check where {this} is in the hierarchy if {nameof(Manager)} is null!"); + + // Checked after Manager because we shouldn't be checking for hierarchy on a detached user anyways. + if (IsAdmin) + return -1; + + foreach (UserRole role in Manager.RoleManager) + { + + if (!HasRole(role)) + continue; + + return role.GetCurrentOrderIndex(); + + } + + throw new InvalidOperationException($"Not sure how this happened, but we didn't find a role that {this} matched with. Not even the default role."); + + } + + #endregion + +} diff --git a/PMDDesktop.Server.Users/UserAccessToken.cs b/PMDDesktop.Server.Users/UserAccessToken.cs new file mode 100644 index 0000000..5fa64fb --- /dev/null +++ b/PMDDesktop.Server.Users/UserAccessToken.cs @@ -0,0 +1,122 @@ +using System.Security.Cryptography; + +namespace PMDDesktop.Server.Users; + +public sealed class UserAccessToken +{ + + /// + /// The time until a expires. + /// + public static readonly TimeSpan TIME_UNTIL_EXPIRE = new(8, 0, 0, 0); // 8 days. + + /// + /// The time until the that at which point the token should be refreshed. + /// + public static readonly TimeSpan REFRESH_THRESHOLD = new(7, 0, 0, 0); // 7 Days. // Ideally if you log in once a week, you'll stay logged in. + + /// + /// How many random characters should be in our + /// + public static readonly int RANDOM_CHAR_COUNT = 64; + + public static readonly char[] RANDOM_CHARS = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + '-', '_' + ]; + + /// + /// Create a new by manually specifying the , , and . + /// + /// The of this . + /// The of this . + /// The of this . + internal UserAccessToken(string token, User forUser, DateTime expires) + { + + TokenString = token; + User = forUser; + ExpiryDate = expires; + + } + + /// + /// Create a new, randomized for . + /// + /// The that this is intended for. + /// + internal static UserAccessToken CreateNewToken(User forUser) + { + + string tokenString = RandomNumberGenerator.GetString(RANDOM_CHARS, RANDOM_CHAR_COUNT); + + return new(tokenString, forUser, DateTime.Now + TIME_UNTIL_EXPIRE); + + } + + /// + /// The that this expires. + /// + public DateTime ExpiryDate { get; private set; } + + /// + /// The unique used to access this . + /// + public string TokenString { get; private set; } + + /// + /// The that this points to. + /// + public User User { get; internal set; } + + /// + /// Check if this needs a refresh according to . + /// + /// Returns true if this tokens needs a refresh. + public bool NeedsRefresh() + { + + return DateTime.UtcNow < ExpiryDate - REFRESH_THRESHOLD; + + } + + /// + /// Refresh and tell the to save. + /// + /// Completes once the saving is finished. + public async Task RefreshToken() + { + + ExpiryDate = DateTime.UtcNow + TIME_UNTIL_EXPIRE; + + await User.WriteNewData(); + + } + + /// + /// Check if this is currently valid. (Not expired) + /// + /// True if this token can be used. + public bool IsTokenValid() + { + + DateTime now = DateTime.UtcNow; + + return now < ExpiryDate; + + } + + /// + /// Tell to revoke this . + /// + /// Completes task once the token is revoked. + public async Task RevokeToken() + { + + await User.RevokeAccessToken(this); + + } + +} diff --git a/PMDDesktop.Server.Users/UserConverter.cs b/PMDDesktop.Server.Users/UserConverter.cs new file mode 100644 index 0000000..8b22e8f --- /dev/null +++ b/PMDDesktop.Server.Users/UserConverter.cs @@ -0,0 +1,159 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users; + + +internal class UserConverter : JsonConverter +{ + + public static readonly string TOKENS_PROPERTY_NAME = "Tokens"; + public static readonly string CONFIG_PROPERTY_NAME = "Config"; + public static readonly string TOKEN_STRING_PROPERTY_NAME = "String"; + public static readonly string TOKEN_EXPIRE_PROPERTY_NAME = "ExpiryDate"; + + public override User Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + + using JsonDocument json = JsonDocument.ParseValue(ref reader); + + List propertyList = [.. json.RootElement.EnumerateObject().Select(element => element.Name)]; + + User user = new() + { + Name = QuickReadString(json, propertyList, nameof(User.Name)), + HashedPassword = QuickReadNullableString(json, propertyList, nameof(User.HashedPassword)), + LoginHandle = QuickReadNullableString(json, propertyList, nameof(User.LoginHandle)), + IsAdmin = QuickReadBoolean(json, propertyList, nameof(User.IsAdmin)) + }; + + propertyList.Remove(TOKENS_PROPERTY_NAME); + user.accessTokens = ReadTokenArray(json, user); + + if (propertyList.Count > 0) + throw new JsonException($"The user json had left-over properties after parsing, such as {propertyList[0]}. These unused properties cannot be accounted for, and may result in data loss. Is it from a newer version?"); + + if (user.IsAlive()) + throw new InvalidOperationException($"{user} should not be attached to a manager at this point, or otherwise \"alive\"."); + + return user; + + } + + public override void Write(Utf8JsonWriter writer, User value, JsonSerializerOptions options) + { + + writer.WriteStartObject(); + + writer.WriteString(nameof(User.Name), value.Name); + writer.WriteString(nameof(User.HashedPassword), value.HashedPassword); + writer.WriteString(nameof(User.LoginHandle), value.LoginHandle); + writer.WriteBoolean(nameof(User.IsAdmin), value.IsAdmin); + + writer.WriteStartArray(TOKENS_PROPERTY_NAME); + foreach (UserAccessToken token in value.accessTokens) + WriteToken(writer, token); + writer.WriteEndArray(); + + writer.WriteEndObject(); + + } + + /// + /// Writes a single JSON object. + /// + /// The to write to. + /// The to write. + private static void WriteToken(Utf8JsonWriter writer, UserAccessToken token) + { + + writer.WriteStartObject(); + + writer.WriteString(TOKEN_STRING_PROPERTY_NAME, token.TokenString); + writer.WriteString(TOKEN_EXPIRE_PROPERTY_NAME, token.ExpiryDate); + + writer.WriteEndObject(); + + } + + /// + /// Quickly read (non-nullable string) from and remove from . + /// + /// The to read from. + /// The to remove from. + /// The to read from and to remove from . + /// Returns the read string. + /// If name is null. + private static string QuickReadString(JsonDocument document, List propertyList, string name) + { + + propertyList.Remove(name); + + return document.RootElement.GetProperty(name).GetString() + ?? throw new JsonException($"{name} should not be null in {document}"); + + } + + /// + /// Quickly read (nullable string) from and remove from . + /// + /// The to read from. + /// The to remove from. + /// The to read from and to remove from . + /// Returns the read string, or null if it is null. + private static string? QuickReadNullableString(JsonDocument document, List propertyList, string name) + { + + propertyList.Remove(name); + + return document.RootElement.GetProperty(name).GetString(); + + } + + /// + /// Quickly read (boolean) from and remove from . + /// + /// The to read from. + /// The to remove from. + /// The to read from and to remove from . + /// Returns the read boolean. + private static bool QuickReadBoolean(JsonDocument document, List propertyList, string name) + { + + propertyList.Remove(name); + + return document.RootElement.GetProperty(name).GetBoolean(); + + } + + /// + /// Reads the array of JSON objects, and returns a based on it. + /// + /// The of a . + /// The object to assign the created s to. + /// Returns a of read s. + /// Throws if any read token string was null. + private static List ReadTokenArray(JsonDocument document, User user) + { + + List tokens = []; + + JsonElement tokenJsonArray = document.RootElement.GetProperty(TOKENS_PROPERTY_NAME); + + foreach (JsonElement token in tokenJsonArray.EnumerateArray()) + { + + string tokenString = token.GetProperty(TOKEN_STRING_PROPERTY_NAME).GetString() + ?? throw new JsonException($"{TOKEN_STRING_PROPERTY_NAME} should not be null in {token}"); + + DateTime expiryDate = token.GetProperty(TOKEN_EXPIRE_PROPERTY_NAME).GetDateTime(); + + tokens.Add(new(tokenString, user, expiryDate)); + + } + + return tokens; + + } + +} diff --git a/PMDDesktop.Server.Users/UserCreationOptions.cs b/PMDDesktop.Server.Users/UserCreationOptions.cs new file mode 100644 index 0000000..c270313 --- /dev/null +++ b/PMDDesktop.Server.Users/UserCreationOptions.cs @@ -0,0 +1,23 @@ +namespace PMDDesktop.Server.Users; + +public sealed record class UserCreationOptions +{ + + /// + /// The initial value to set . + /// + public required string DisplayName { get; set; } + + /// + /// The value to hash to create . + /// + public string? PlainTextPassword { get; set; } + + /// + /// The initial value to set . Must be unique. + /// + public string? LoginHandle { get; set; } + + public bool IsAdmin { get; set; } = false; + +} diff --git a/PMDDesktop.Server.Users/UserManager.cs b/PMDDesktop.Server.Users/UserManager.cs new file mode 100644 index 0000000..35a370d --- /dev/null +++ b/PMDDesktop.Server.Users/UserManager.cs @@ -0,0 +1,255 @@ +using PMDDesktop.Server.Users.Roles; +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace PMDDesktop.Server.Users; + +public sealed class UserManager : IEnumerable, IUserIndexable, IRoleIndexable +{ + + private static readonly UserCreationOptions DEFAULT_ADMIN_OPTIONS = new() + { + + DisplayName = "Default Admin User", + LoginHandle = "admin", + PlainTextPassword = "admin", + IsAdmin = true + + }; + + public UserManager() + { + + RoleManager = new(this); + + } + + /// + /// attached to this + /// Used when checking for s and their s. + /// + public UserRoleManager RoleManager { get; } + + /// + /// Does this actually write to files? + /// If this is false, file writes to the filesystem/disk will be skipped. + /// + /// + /// This is property as "false" is very useful in unit testing. Should probably be "true" during runtime. + /// + public bool WritingEnabled { get; private set; } = false; + + /// + /// This is a dictionary with all the s mapped to their . + /// + /// + /// This dictionary's entries are not managed by the , but instead by the s as they are attached/detached. Do not let write to this dictionary. + /// + internal Dictionary guids = []; + + /// + /// This is a dictionary with all the s mapped to their . + /// + /// + /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary. + /// + internal Dictionary loginHandles = []; + + /// + /// This is a dictionary with all the s mapped to their . + /// + /// + /// This dictionary's entries are not managed by the , but instead by the s inside . Do not let write to this dictionary. + /// + internal Dictionary accessTokens = []; + + /// + /// Loads from the 'users' and 'roles' directories, and enables . + /// + /// + /// This can throw under many, many circumstances. If it does, cancel all operations and make sure the error is conveyed to the user. + /// + [ExcludeFromCodeCoverage] + public async Task LoadFromFilesAndEnableWriting() + { + + await LoadAllUserData(); + + await RoleManager.LoadFromFiles(); + + // This needs to be set after loading to prevent premature writing while loading. + WritingEnabled = true; + + } + + /// + /// This manages creating objects by loading their data from the "users" folder. + /// + [ExcludeFromCodeCoverage] + private async Task LoadAllUserData() + { + + string userRootDirPath = Path.Combine(AppContext.BaseDirectory, "users"); + + if (!Directory.Exists(userRootDirPath)) + Directory.CreateDirectory(userRootDirPath); + + foreach (string subDirPath in Directory.EnumerateDirectories(userRootDirPath)) + { + + string guidParsable = Path.GetFileName(subDirPath); + + // This is not okay. All user folders should have GUIDs are their names. + if (!Guid.TryParse(guidParsable, out Guid loadedGUID)) + throw new Exception($"Couldn't parse {guidParsable} as a GUID at {subDirPath}"); + + string userFilePath = Path.Join(subDirPath, User.USER_FILE_NAME); + + using FileStream readStream = File.OpenRead(userFilePath); + + await LoadAndAddUserJson(readStream, loadedGUID); + + } + + // If no users were loaded, make a default Admin user to ensure there's a way to log in. + if (guids.Count == 0) + await TryCreateDefaultAdminUser(); + + } + + /// + /// Internal function for loading a JSON stream and adding the resulting to this . + /// + /// A JSON stream with data inside. + /// + /// + /// This can be safely unit tested, as it takes in a stream instead of reading from a file. + /// + internal async Task LoadAndAddUserJson(Stream stream, Guid guid) + { + + User deserialized = JsonSerializer.Deserialize(stream, AppInfo.JSON_OPTIONS) + ?? throw new Exception($"Deserialized save data from {stream} was null."); + + deserialized.GUID = guid; // GUID will be randomized by default, we need to load the previous GUID. + + deserialized.AttachToManager(this); + + return deserialized; + + } + + public IEnumerator GetEnumerator() + { + return guids.Values.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Try to get a using the string of an access token. Never use this function for any purpose other than authorization. + /// This function respects token expiration, and may clear tokens that have expired. It may also refresh a token's expiration if a certain amount of time has passed. + /// If this is , this function may write to the filesystem/disk to update token information. + /// + /// The token string corresponding to a , but not an error if it doesn't. + /// + public bool TryUseAccessToken(string tokenString, [NotNullWhen(true)] out User? user) + { + + return TryUseAccessToken(tokenString, out user, out _); + + } + + /// + /// Try to get a using the string of an access token. Never use this function for any purpose other than authorization. + /// This function respects token expiration, and may clear tokens that have expired. It may also refresh a token's expiration if a certain amount of time has passed. + /// If this is , this function may write to the filesystem/disk to update token information. + /// + /// The token string corresponding to a , but not an error if it doesn't. + /// True if access should be allowed, otherwise false. + /// Sometimes, and may not be null, even if false was returned. In this case, do not give access, but you may inspect the results. + public bool TryUseAccessToken(string tokenString, [NotNullWhen(true)] out User? user, [NotNullWhen(true)] out UserAccessToken? token) + { + + accessTokens.TryGetValue(tokenString, out token); + user = token?.User; + + if (token is null || user is null) + return false; + + if (!token.IsTokenValid()) + return false; + + return true; + + } + + public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) + { + + return guids.TryGetValue(GUID, out user); + + } + + /// + /// Try to get a by their . Will return false and will be null if not found. + /// + /// The of the you're looking for. + /// The found , if any was found. Will be null if the return was false. + /// True if the is found, and will not be null. + public bool TryGetUserByLoginHandle(string handle, [NotNullWhen(true)] out User? user) + { + + return loginHandles.TryGetValue(handle, out user); + + } + + /// + /// Try to create a new , will return null if it could not be done. + /// + /// to specify how the should be created. + /// The created , or null if it failed. + /// Primary failing condition is if the is not unique. + public async Task TryCreateUser(UserCreationOptions options) + { + + User user = new() + { + Name = options.DisplayName, + IsAdmin = options.IsAdmin + }; + + if (options.LoginHandle is not null) + if (!await user.TrySetLoginHandle(options.LoginHandle)) + return null; + if (options.PlainTextPassword is not null) + await user.SetPassword(options.PlainTextPassword); + + user.AttachToManager(this); + await user.WriteNewData(); + return user; + + } + + /// + /// Create a new User account with handle "admin" and password "admin" and admin permissions. + /// Useful when starting the server for the first time without any users. + /// + /// + internal async Task TryCreateDefaultAdminUser() + { + + return await TryCreateUser(DEFAULT_ADMIN_OPTIONS); + + } + + public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) + { + return RoleManager.TryGetRole(GUID, out role); + } + +} diff --git a/PMDDesktop.Server.Users/UserReference.cs b/PMDDesktop.Server.Users/UserReference.cs new file mode 100644 index 0000000..d974a17 --- /dev/null +++ b/PMDDesktop.Server.Users/UserReference.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users; + +[JsonConverter(typeof(UserReferenceConverter))] +public sealed class UserReference +{ + + /// + /// Stored corresponding to . + /// + public Guid GUID { get; private set; } + + /// + /// Create a new pointing to 's . + /// This allows you to find the again after serialization and deserialization. + /// + /// The you'd like to keep a reference to via . + public UserReference(User initalValue) + { + + GUID = initalValue.GUID; + + } + + /// + /// Create a new pointing exactly to + /// The existance of any with this is not checked until is called. + /// + /// The you'd like to point to. + internal UserReference(Guid directGuid) + { + + GUID = directGuid; + + } + + /// + /// Try to get a from its . + /// + /// The to use to find . + /// The result of the search. Will be null if false is returned, and contain a if true. + /// True if the was found and has a value, otherwise false. + public bool TryGetUser(IUserIndexable indexable, [NotNullWhen(true)] out User? user) + { + + return indexable.TryGetUser(GUID, out user); + + } + +} diff --git a/PMDDesktop.Server.Users/UserReferenceConverter.cs b/PMDDesktop.Server.Users/UserReferenceConverter.cs new file mode 100644 index 0000000..4c76088 --- /dev/null +++ b/PMDDesktop.Server.Users/UserReferenceConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PMDDesktop.Server.Users; + + +internal class UserReferenceConverter : JsonConverter +{ + public override UserReference Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"{Type} was expecting a string token to deserialize."); + + string guidString = reader.GetString() ?? throw new JsonException($"We read a null string while trying to deserialize {Type}."); + + Guid guid = Guid.Parse(guidString); + + return new UserReference(guid); + + } + + public override void Write(Utf8JsonWriter writer, UserReference value, JsonSerializerOptions options) + { + + writer.WriteStringValue(value.GUID.ToString()); + + } +} diff --git a/PMDDesktop.Server/Game/GameServer.cs b/PMDDesktop.Server/Game/GameServer.cs index 0a755f7..df122b2 100644 --- a/PMDDesktop.Server/Game/GameServer.cs +++ b/PMDDesktop.Server/Game/GameServer.cs @@ -1,10 +1,12 @@ using PMDDesktop.Server.Assets; using PMDDesktop.Server.Saving; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; using System.Diagnostics.CodeAnalysis; namespace PMDDesktop.Server.Game; -public class GameServer : IAssetIndexable, ISaveDataIndexable +public class GameServer : IAssetIndexable, ISaveDataIndexable, IUserIndexable, IRoleIndexable { public GameServer() @@ -74,4 +76,14 @@ public bool TryGetSave(Guid GUID, [NotNullWhen(true)] out T? data) where T : return State.TryGetSave(GUID, out data); } + public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) + { + return State.TryGetUser(GUID, out user); + } + + public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) + { + return ((IRoleIndexable)State).TryGetRole(GUID, out role); + } + } diff --git a/PMDDesktop.Server/Game/GameState.cs b/PMDDesktop.Server/Game/GameState.cs index 9b7d8fb..0250dba 100644 --- a/PMDDesktop.Server/Game/GameState.cs +++ b/PMDDesktop.Server/Game/GameState.cs @@ -1,12 +1,24 @@ using PMDDesktop.Server.Assets; using PMDDesktop.Server.Saving; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; using System.Diagnostics.CodeAnalysis; namespace PMDDesktop.Server.Game; -public class GameState : IAssetIndexable, ISaveDataIndexable +public class GameState : IAssetIndexable, ISaveDataIndexable, IUserIndexable, IRoleIndexable { + public GameState() + { + + Saves = []; + Assets = new(); + Users = new(); + Roles = Users.RoleManager; + + } + [ExcludeFromCodeCoverage] public static async Task CreateAndLoadFiles() { @@ -25,6 +37,7 @@ public async Task LoadAllFiles() await Saves.LoadFromFilesAndEnableWriting(); await Assets.LoadFromFiles(); + await Users.LoadFromFilesAndEnableWriting(); } @@ -48,7 +61,19 @@ public bool TryGetSave(Guid GUID, [NotNullWhen(true)] out T? data) where T : return Saves.TryGetSave(GUID, out data); } - public SaveDataManager Saves { get; } = new(); - public AssetManager Assets { get; } = new(); + public bool TryGetUser(Guid GUID, [NotNullWhen(true)] out User? user) + { + return Users.TryGetUser(GUID, out user); + } + + public bool TryGetRole(Guid GUID, [NotNullWhen(true)] out UserRole? role) + { + return Roles.TryGetRole(GUID, out role); + } + + public SaveDataManager Saves { get; } + public AssetManager Assets { get; } + public UserManager Users { get; } + public UserRoleManager Roles { get; } } diff --git a/PMDDesktop.Server/PMDDesktop.Server.csproj b/PMDDesktop.Server/PMDDesktop.Server.csproj index af927f5..47aaf46 100644 --- a/PMDDesktop.Server/PMDDesktop.Server.csproj +++ b/PMDDesktop.Server/PMDDesktop.Server.csproj @@ -19,6 +19,7 @@ + diff --git a/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs new file mode 100644 index 0000000..0d3c6d4 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Auth/LoginRequestHandler.cs @@ -0,0 +1,26 @@ +using PMDDesktop.Requests.Auth; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; + +namespace PMDDesktop.Server.RequestHandlers.Auth; + +public class LoginRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(LoginRequest request, GameServer game, User? requestingUser) + { + + if (!game.State.Users.TryGetUserByLoginHandle(request.Handle, out User? user)) + throw new UserRequestException($"Invalid username or password."); + + if (!await user.VerifyPassword(request.Password)) + throw new UserRequestException($"Invalid username or password."); // Throw the same thing to be more "secure" I guess. + + return new() + { + AuthorizationToken = (await user.CreateAccessToken()).TokenString + }; + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Auth/WhoAmIRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Auth/WhoAmIRequestHandler.cs new file mode 100644 index 0000000..5457b15 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Auth/WhoAmIRequestHandler.cs @@ -0,0 +1,23 @@ +using PMDDesktop.Requests.Auth; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; + +namespace PMDDesktop.Server.RequestHandlers.Auth; + +public class WhoAmIRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(WhoAmIRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"Please send your valid token in the Authorization header to access this endpoint."); + + return new() + { + GUID = requestingUser.GUID + }; + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs new file mode 100644 index 0000000..d7f712f --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/GenericResponseCreators.cs @@ -0,0 +1,76 @@ +using PMDDesktop.Requests.Permissions; +using PMDDesktop.Requests.Roles; +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers; + +internal static class GenericResponseCreators +{ + + internal static UserInfoResponse CreateUserInfoResponse(User fromPOV, User ofUser) + { + + bool canSeeLoginInfo = fromPOV.HasPermission(UserPermission.MANAGE_USERS); + + return new() + { + + GUID = ofUser.GUID, + DisplayName = ofUser.Name, + HasPassword = canSeeLoginInfo ? ofUser.HasPassword() : null, + LoginHandle = canSeeLoginInfo ? ofUser.LoginHandle : null + + }; + + } + + internal static RoleInfoResponse CreateRoleInfoResponse(User fromPOV, UserRole ofRole) + { + + bool canSeePermissionInfo = fromPOV.HasPermission(UserPermission.MANAGE_USERS); + + Dictionary? permissions; + + if (canSeePermissionInfo) + { + + permissions = []; + foreach (UserPermission permission in UserPermission.ALL) + permissions.Add(permission.DataName, ofRole.GetPermission(permission)); + + } + else + { + + permissions = null; + + } + + return new() + { + + DisplayName = ofRole.Name, + GUID = ofRole.GUID, + Permissions = permissions, + IndexInRoleOrder = ofRole.GetCurrentOrderIndex(), + IsDefaultRole = ofRole.IsDefaultRole + + }; + + } + + internal static PermissionInfoResponse CreatePermissionInfoResponse(UserPermission ofPermission) + { + + return new() + { + DataName = ofPermission.DataName, + FriendlyDescription = ofPermission.FriendlyDescription, + FriendlyName = ofPermission.FriendlyName + }; + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs index 5c92853..d715491 100644 --- a/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/IRequestHandler.cs @@ -1,6 +1,8 @@ -using PMDDesktop.Exceptions; +using System.Diagnostics.CodeAnalysis; +using PMDDesktop.Exceptions; using PMDDesktop.Requests; using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; using PMDDesktop.Utils; namespace PMDDesktop.Server.RequestHandlers; @@ -44,6 +46,22 @@ static void AddApiPoints(GameServer server) } + static bool TryGetUserFromContext(UserManager manager, HttpContext context, [NotNullWhen(true)] out User? user, [NotNullWhen(true)] out UserAccessToken? accessToken) + { + + string? token = context.Request.Headers.Authorization; + + if (token is null) + { + user = null; + accessToken = null; + return false; + } + + return manager.TryUseAccessToken(token, out user, out accessToken); + + } + /// /// Process the full request as needed. /// diff --git a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs index 687ee38..9dfdd1a 100644 --- a/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/JsonRequestHandler.cs @@ -1,5 +1,6 @@ using PMDDesktop.Requests; using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; using PMDDesktop.Utils; using System.Reflection; using System.Text; @@ -158,15 +159,19 @@ public async Task HandleRequest(HttpContext context, GameServer game) } + if (IRequestHandler.TryGetUserFromContext(game.State.Users, context, out User? user, out UserAccessToken? token)) + if (token.NeedsRefresh()) + await token.RefreshToken(); + try { context.Response.StatusCode = 200; context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(CreateResponse(deserialized, game), AppInfo.NETWORK_JSON_OPTIONS); + await context.Response.WriteAsJsonAsync(await CreateResponse(deserialized, game, user), AppInfo.NETWORK_JSON_OPTIONS); } - catch (UserRequestException e) // Intended for user-facing errors, like bad inputs, + catch (UserRequestException e) // Intended for user-facing errors, like bad inputs, { await WritePlainText(context, 400, e.Message); @@ -200,6 +205,6 @@ private static async Task WritePlainText(HttpContext context, int code, string e } - protected abstract TRes CreateResponse(TReq request, GameServer game); + protected abstract Task CreateResponse(TReq request, GameServer game, User? requestingUser); } diff --git a/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs index 3cbab7b..70ce47a 100644 --- a/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/ListSpeciesRequestHandler.cs @@ -1,13 +1,14 @@ using PMDDesktop.Requests; using PMDDesktop.Server.Assets.Data; using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; namespace PMDDesktop.Server.RequestHandlers; public class ListSpeciesRequestHandler : JsonRequestHandler { - protected override ListSpeciesResponse CreateResponse(ListSpeciesRequest request, GameServer game) + protected override async Task CreateResponse(ListSpeciesRequest request, GameServer game, User? requestingUser) { return new() diff --git a/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs index 066f471..56092e4 100644 --- a/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs +++ b/PMDDesktop.Server/RequestHandlers/ListTypesRequestHandler.cs @@ -1,13 +1,14 @@ using PMDDesktop.Requests; using PMDDesktop.Server.Assets.Data; using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; namespace PMDDesktop.Server.RequestHandlers; public class ListTypesRequestHandler : JsonRequestHandler { - protected override ListTypesResponse CreateResponse(ListTypesRequest request, GameServer game) + protected override async Task CreateResponse(ListTypesRequest request, GameServer game, User? requestingUser) { return new() diff --git a/PMDDesktop.Server/RequestHandlers/Permissions/ListPermissionsRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Permissions/ListPermissionsRequestHandler.cs new file mode 100644 index 0000000..fafd792 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Permissions/ListPermissionsRequestHandler.cs @@ -0,0 +1,21 @@ +using PMDDesktop.Requests.Permissions; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Permissions; + +public class ListPermissionsRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(ListPermissionsRequest request, GameServer game, User? requestingUser) + { + + return new() + { + Permissions = [.. UserPermission.ALL.Select(permission => GenericResponseCreators.CreatePermissionInfoResponse(permission))] + }; + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Roles/CreateRoleRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/CreateRoleRequestHandler.cs new file mode 100644 index 0000000..1bcab1f --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Roles/CreateRoleRequestHandler.cs @@ -0,0 +1,31 @@ +using PMDDesktop.Requests.Roles; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Roles; + +public class CreateRoleRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(CreateRoleRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + // To create roles, you need the MANAGE_ROLES permission. + if (!requestingUser.HasPermission(UserPermission.MANAGE_ROLES)) + throw new UserRequestException($"You need permission to manage roles to access this endpoint."); + + UserRole role = await game.State.Roles.TryCreateRole() + ?? throw new Exception($"Unable to create role."); + + if (request.NewName is not null) + await role.SetName(request.NewName); + + return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role); + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Roles/GetRoleInfoRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/GetRoleInfoRequestHandler.cs new file mode 100644 index 0000000..ac72557 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Roles/GetRoleInfoRequestHandler.cs @@ -0,0 +1,24 @@ +using PMDDesktop.Requests.Roles; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Roles; + +public class GetRoleInfoRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(GetRoleInfoRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!game.TryGetRole(request.GUID, out UserRole? role)) + throw new UserRequestException($"{request.GUID} does not match a role."); + + return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role); + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Roles/ListUsersRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/ListUsersRequestHandler.cs new file mode 100644 index 0000000..6d2de5a --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Roles/ListUsersRequestHandler.cs @@ -0,0 +1,23 @@ +using PMDDesktop.Requests.Roles; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; + +namespace PMDDesktop.Server.RequestHandlers.Roles; + +public class ListRolesRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(ListRolesRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + return new() + { + Roles = [.. game.State.Roles.Select(role => GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role))] + }; + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs new file mode 100644 index 0000000..e6d72bd --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Roles/SetRoleNameRequestHandler.cs @@ -0,0 +1,32 @@ +using PMDDesktop.Requests.Roles; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Roles; + +public class SetRoleNameRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(SetRoleNameRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!requestingUser.HasPermission(UserPermission.MANAGE_ROLES)) + throw new UserRequestException($"You need permission to manage roles to access this endpoint."); + + if (!game.TryGetRole(request.GUID, out UserRole? role)) + throw new UserRequestException($"{request.GUID} does not match a role."); + + if (!requestingUser.HigherThan(role)) + throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation."); + + await role.SetName(request.NewName); + + return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role); + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Roles/SetRolePermissionRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Roles/SetRolePermissionRequestHandler.cs new file mode 100644 index 0000000..da5412b --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Roles/SetRolePermissionRequestHandler.cs @@ -0,0 +1,35 @@ +using PMDDesktop.Requests.Roles; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Roles; + +public class SetRolePermissionRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(SetRolePermissionRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!requestingUser.HasPermission(UserPermission.MANAGE_ROLES)) + throw new UserRequestException($"You need permission to manage roles to access this endpoint."); + + if (!game.TryGetRole(request.GUID, out UserRole? role)) + throw new UserRequestException($"{request.GUID} does not match a role."); + + if (!requestingUser.HigherThan(role)) + throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation."); + + if (!UserPermission.TryGetPermissionByDataName(request.PermissionDataName, out UserPermission? permission)) + throw new UserRequestException($"Could not find a permission with the name '{request.PermissionDataName}' to set."); + + await role.SetPermission(permission, request.NewPermissionValue); + + return GenericResponseCreators.CreateRoleInfoResponse(requestingUser, role); + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Users/CreateUserRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/CreateUserRequestHandler.cs new file mode 100644 index 0000000..7246c19 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/CreateUserRequestHandler.cs @@ -0,0 +1,45 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class CreateUserRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(CreateUserRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + // To create users, you need the MANAGE_USERS permission. + if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS)) + throw new UserRequestException($"You need permission to manage users to access this endpoint."); + + // TEMPORARY SOLUTION + // TODO: Replace this with a more elegant solution. I think we should make a new class or struct to hold these types of super normalized strings. + bool validHandle = request.NewLoginHandle is null || request.NewLoginHandle.All(letter => char.IsAsciiLetterLower(letter) || char.IsAsciiDigit(letter) || letter == '-'); + + if (!validHandle) + throw new UserRequestException($"The handle is invalid. Please make sure you use lowercase letters, numbers, or hyphens."); + + if (request.NewLoginHandle is not null && game.State.Users.TryGetUserByLoginHandle(request.NewLoginHandle, out _)) + throw new UserRequestException($"Provided login handle '{request.NewLoginHandle}' is already in use."); + + UserCreationOptions options = new() + { + DisplayName = request.NewName, + LoginHandle = request.NewLoginHandle, + PlainTextPassword = request.NewPassword + }; + + User user = await game.State.Users.TryCreateUser(options) + ?? throw new Exception($"Unable to create user."); + + return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user); + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs new file mode 100644 index 0000000..d164118 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/GetUserInfoRequestHandler.cs @@ -0,0 +1,23 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class GetUserInfoRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(GetUserInfoRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!game.TryGetUser(request.GUID, out User? user)) + throw new UserRequestException($"{request.GUID} does not match a user."); + + return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user); + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Users/ListUsersRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/ListUsersRequestHandler.cs new file mode 100644 index 0000000..db728de --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/ListUsersRequestHandler.cs @@ -0,0 +1,23 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class ListUsersRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(ListUsersRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + return new() + { + Users = [.. game.State.Users.Select(user => GenericResponseCreators.CreateUserInfoResponse(requestingUser, user))] + }; + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs new file mode 100644 index 0000000..1767f9f --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserLoginHandleRequestHandler.cs @@ -0,0 +1,43 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class SetUserLoginHandleRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(SetUserLoginHandleRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!game.TryGetUser(request.GUID, out User? user)) + throw new UserRequestException($"{request.GUID} does not match a user."); + + // To change the login handle of someone who isn't yourself, you need the MANAGE_USERS permission. + if (user != requestingUser) + { + if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS)) + throw new UserRequestException($"You need permission to manage users to access this endpoint."); + if (!requestingUser.HigherThan(user)) + throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation."); + } + + // TEMPORARY SOLUTION + // TODO: Replace this with a more elegant solution. I think we should make a new class or struct to hold these types of super normalized strings. + bool validHandle = request.NewLoginHandle is null || request.NewLoginHandle.All(letter => char.IsAsciiLetterLower(letter) || char.IsAsciiDigit(letter) || letter == '-'); + + if (!validHandle) + throw new UserRequestException($"The handle is invalid. Please make sure you use lowercase letters, numbers, or hyphens."); + + if (!await user.TrySetLoginHandle(request.NewLoginHandle)) + throw new UserRequestException($"Unable to set the login handle to '{request.NewLoginHandle}'. It may not be unique."); + + return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user); + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs new file mode 100644 index 0000000..4e657ab --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserNameRequestHandler.cs @@ -0,0 +1,35 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class SetUserNameRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(SetUserNameRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!game.TryGetUser(request.GUID, out User? user)) + throw new UserRequestException($"{request.GUID} does not match a user."); + + // To change the username of someone who isn't yourself, you need the MANAGE_USERS permission. + if (user != requestingUser) + { + if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS)) + throw new UserRequestException($"You need permission to manage users to access this endpoint."); + if (!requestingUser.HigherThan(user)) + throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation."); + } + + await user.SetName(request.NewName); + + return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user); + + } + +} diff --git a/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs b/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs new file mode 100644 index 0000000..4a174c9 --- /dev/null +++ b/PMDDesktop.Server/RequestHandlers/Users/SetUserPasswordRequestHandler.cs @@ -0,0 +1,35 @@ +using PMDDesktop.Requests.Users; +using PMDDesktop.Server.Game; +using PMDDesktop.Server.Users; +using PMDDesktop.Server.Users.Roles; + +namespace PMDDesktop.Server.RequestHandlers.Users; + +public class SetUserPasswordRequestHandler : JsonRequestHandler +{ + + protected override async Task CreateResponse(SetUserPasswordRequest request, GameServer game, User? requestingUser) + { + + if (requestingUser is null) + throw new UserRequestException($"You need to be logged in to access this endpoint."); + + if (!game.TryGetUser(request.GUID, out User? user)) + throw new UserRequestException($"{request.GUID} does not match a user."); + + // To change the login handle of someone who isn't yourself, you need the MANAGE_USERS permission. + if (user != requestingUser) + { + if (!requestingUser.HasPermission(UserPermission.MANAGE_USERS)) + throw new UserRequestException($"You need permission to manage users to access this endpoint."); + if (!requestingUser.HigherThan(user)) + throw new UserRequestException($"You are not high enough in the role hierarchy to perform this operation."); + } + + await user.SetPassword(request.NewPassword); + + return GenericResponseCreators.CreateUserInfoResponse(requestingUser, user); + + } + +} diff --git a/PMDDesktop.Server/User.cs b/PMDDesktop.Server/User.cs deleted file mode 100644 index 7435846..0000000 --- a/PMDDesktop.Server/User.cs +++ /dev/null @@ -1,20 +0,0 @@ -using PMDDesktop.Server.Saving; - -namespace PMDDesktop.Server; - -[SaveSubdirectory("users")] -internal sealed class User : SaveData -{ - - public string displayName; - public string loginHandle; - - private User(string handle) : base() - { - - loginHandle = handle; - displayName = handle.ToUpper(); - - } - -} diff --git a/PMDDesktop.slnx b/PMDDesktop.slnx index 12cfc68..c3e0818 100644 --- a/PMDDesktop.slnx +++ b/PMDDesktop.slnx @@ -2,6 +2,8 @@ + + diff --git a/PMDDesktop/Requests/Auth/LoginRequest.cs b/PMDDesktop/Requests/Auth/LoginRequest.cs new file mode 100644 index 0000000..28611e4 --- /dev/null +++ b/PMDDesktop/Requests/Auth/LoginRequest.cs @@ -0,0 +1,10 @@ +namespace PMDDesktop.Requests.Auth; + +[ServerRequest("/api/auth/login")] +public sealed class LoginRequest : ServerRequest +{ + + public required string Handle { get; set; } + public required string Password { get; set; } + +} diff --git a/PMDDesktop/Requests/Auth/LoginResponse.cs b/PMDDesktop/Requests/Auth/LoginResponse.cs new file mode 100644 index 0000000..81e8c17 --- /dev/null +++ b/PMDDesktop/Requests/Auth/LoginResponse.cs @@ -0,0 +1,8 @@ +namespace PMDDesktop.Requests.Auth; + +public sealed class LoginResponse : ServerResponse +{ + + public required string AuthorizationToken { get; set; } + +} diff --git a/PMDDesktop/Requests/Auth/WhoAmIRequest.cs b/PMDDesktop/Requests/Auth/WhoAmIRequest.cs new file mode 100644 index 0000000..dc72dc6 --- /dev/null +++ b/PMDDesktop/Requests/Auth/WhoAmIRequest.cs @@ -0,0 +1,9 @@ +namespace PMDDesktop.Requests.Auth; + +[ServerRequest("/api/auth/who-am-i")] +public sealed class WhoAmIRequest : ServerRequest +{ + + // Expecting Authorization header for this request. + +} diff --git a/PMDDesktop/Requests/Auth/WhoAmIResponse.cs b/PMDDesktop/Requests/Auth/WhoAmIResponse.cs new file mode 100644 index 0000000..afa4623 --- /dev/null +++ b/PMDDesktop/Requests/Auth/WhoAmIResponse.cs @@ -0,0 +1,8 @@ +namespace PMDDesktop.Requests.Auth; + +public sealed class WhoAmIResponse : ServerResponse +{ + + public required Guid GUID {get; set;} + +} diff --git a/PMDDesktop/Requests/Permissions/ListPermissionsRequest.cs b/PMDDesktop/Requests/Permissions/ListPermissionsRequest.cs new file mode 100644 index 0000000..bce7379 --- /dev/null +++ b/PMDDesktop/Requests/Permissions/ListPermissionsRequest.cs @@ -0,0 +1,7 @@ +namespace PMDDesktop.Requests.Permissions; + +[ServerRequest("/api/permissions/list")] +public sealed class ListPermissionsRequest : ServerRequest +{ + +} diff --git a/PMDDesktop/Requests/Permissions/ListPermissionsResponse.cs b/PMDDesktop/Requests/Permissions/ListPermissionsResponse.cs new file mode 100644 index 0000000..c88d2a8 --- /dev/null +++ b/PMDDesktop/Requests/Permissions/ListPermissionsResponse.cs @@ -0,0 +1,11 @@ +namespace PMDDesktop.Requests.Permissions; + +public sealed class ListPermissionsResponse : ServerResponse +{ + + /// + /// All currently existing roles, presented in order. + /// + public required PermissionInfoResponse[] Permissions { get; set; } + +} diff --git a/PMDDesktop/Requests/Permissions/PermissionInfoResponse.cs b/PMDDesktop/Requests/Permissions/PermissionInfoResponse.cs new file mode 100644 index 0000000..eafbdae --- /dev/null +++ b/PMDDesktop/Requests/Permissions/PermissionInfoResponse.cs @@ -0,0 +1,21 @@ +namespace PMDDesktop.Requests.Permissions; + +public sealed class PermissionInfoResponse : ServerResponse +{ + + /// + /// This is the name used to identify this permission. Should be sent back exactly when talking about this permission with the server. + /// + public required string DataName { get; set; } + + /// + /// The name that's best shown to users. + /// + public required string FriendlyName { get; set; } + + /// + /// A description explaining what the permission does. + /// + public required string FriendlyDescription { get; set; } + +} diff --git a/PMDDesktop/Requests/Roles/CreateRoleRequest.cs b/PMDDesktop/Requests/Roles/CreateRoleRequest.cs new file mode 100644 index 0000000..532a6f3 --- /dev/null +++ b/PMDDesktop/Requests/Roles/CreateRoleRequest.cs @@ -0,0 +1,14 @@ +namespace PMDDesktop.Requests.Roles; + + +[ServerRequest("/api/roles/create")] +public sealed class CreateRoleRequest : ServerRequest +{ + + /// + /// Optional name for the new role. + /// It will be assigned a default name otherwise. + /// + public string? NewName { get; set; } + +} diff --git a/PMDDesktop/Requests/Roles/GetRoleInfoRequest.cs b/PMDDesktop/Requests/Roles/GetRoleInfoRequest.cs new file mode 100644 index 0000000..e853c37 --- /dev/null +++ b/PMDDesktop/Requests/Roles/GetRoleInfoRequest.cs @@ -0,0 +1,10 @@ +namespace PMDDesktop.Requests.Roles; + +[ServerRequest("/api/roles/get-info")] +public sealed class GetRoleInfoRequest : ServerRequest +{ + + [QueryParameter] + public required Guid GUID { get; set; } + +} diff --git a/PMDDesktop/Requests/Roles/ListRolesRequest.cs b/PMDDesktop/Requests/Roles/ListRolesRequest.cs new file mode 100644 index 0000000..168a3bf --- /dev/null +++ b/PMDDesktop/Requests/Roles/ListRolesRequest.cs @@ -0,0 +1,7 @@ +namespace PMDDesktop.Requests.Roles; + +[ServerRequest("/api/roles/list")] +public sealed class ListRolesRequest : ServerRequest +{ + +} diff --git a/PMDDesktop/Requests/Roles/ListRolesResponse.cs b/PMDDesktop/Requests/Roles/ListRolesResponse.cs new file mode 100644 index 0000000..89f7247 --- /dev/null +++ b/PMDDesktop/Requests/Roles/ListRolesResponse.cs @@ -0,0 +1,11 @@ +namespace PMDDesktop.Requests.Roles; + +public sealed class ListRolesResponse : ServerResponse +{ + + /// + /// All currently existing roles, presented in order. + /// + public required RoleInfoResponse[] Roles { get; set; } + +} diff --git a/PMDDesktop/Requests/Roles/RoleInfoResponse.cs b/PMDDesktop/Requests/Roles/RoleInfoResponse.cs new file mode 100644 index 0000000..205436b --- /dev/null +++ b/PMDDesktop/Requests/Roles/RoleInfoResponse.cs @@ -0,0 +1,32 @@ +namespace PMDDesktop.Requests.Roles; + +public sealed class RoleInfoResponse : ServerResponse +{ + + /// + /// The displayed name of the role. + /// + public required string DisplayName { get; set; } + + /// + /// The of the role. + /// + public required Guid GUID { get; set; } + + /// + /// Dictionary of Permission "data names" (IDs) and whether they're enabled, disabled, or neither. + /// + public required Dictionary? Permissions { get; set; } + + /// + /// The index in which this role is evaluated in the overall role order. + /// Starting at 0, a number closer to 0 is evaluated before numbers further from 0, thus a higher-priority. + /// + public required int IndexInRoleOrder { get; set; } + + /// + /// Is this the "default" role that everyone is assigned and can't be assigned or unassigned? + /// + public required bool IsDefaultRole { get; set; } + +} diff --git a/PMDDesktop/Requests/Roles/SetRoleNameRequest.cs b/PMDDesktop/Requests/Roles/SetRoleNameRequest.cs new file mode 100644 index 0000000..4dcd5a5 --- /dev/null +++ b/PMDDesktop/Requests/Roles/SetRoleNameRequest.cs @@ -0,0 +1,18 @@ +namespace PMDDesktop.Requests.Roles; + + +[ServerRequest("/api/roles/set-name")] +public sealed class SetRoleNameRequest : ServerRequest +{ + + /// + /// The of the role you're trying to set the name of. + /// + public required Guid GUID { get; set; } + + /// + /// New name for the new role. + /// + public required string NewName { get; set; } + +} diff --git a/PMDDesktop/Requests/Roles/SetRolePermissionRequest.cs b/PMDDesktop/Requests/Roles/SetRolePermissionRequest.cs new file mode 100644 index 0000000..65deb6a --- /dev/null +++ b/PMDDesktop/Requests/Roles/SetRolePermissionRequest.cs @@ -0,0 +1,24 @@ +namespace PMDDesktop.Requests.Roles; + +[ServerRequest("/api/roles/set-permission")] +public sealed class SetRolePermissionRequest : ServerRequest +{ + + /// + /// The of the role you're trying to edit. + /// + public required Guid GUID { get; set; } + + /// + /// The "data name" of the permission you're trying to edit. + /// + public required string PermissionDataName { get; set; } + + /// + /// The value you'd like to set the permission to. + /// True allows this permission for this role, false denies it, and null makes the role not affect this permission. + /// When evaluating permissions for a user, they check the highest role they have assigned with a non-null permission value for that permission. + /// + public required bool? NewPermissionValue { get; set; } + +} diff --git a/PMDDesktop/Requests/Users/CreateUserRequest.cs b/PMDDesktop/Requests/Users/CreateUserRequest.cs new file mode 100644 index 0000000..f91ea13 --- /dev/null +++ b/PMDDesktop/Requests/Users/CreateUserRequest.cs @@ -0,0 +1,13 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/create")] +public sealed class CreateUserRequest : ServerRequest +{ + + public required string NewName { get; set; } + + public required string? NewLoginHandle { get; set; } + + public required string? NewPassword { get; set; } + +} diff --git a/PMDDesktop/Requests/Users/GetUserInfoRequest.cs b/PMDDesktop/Requests/Users/GetUserInfoRequest.cs new file mode 100644 index 0000000..80c4596 --- /dev/null +++ b/PMDDesktop/Requests/Users/GetUserInfoRequest.cs @@ -0,0 +1,10 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/get-info")] +public sealed class GetUserInfoRequest : ServerRequest +{ + + [QueryParameter] + public required Guid GUID { get; set; } + +} diff --git a/PMDDesktop/Requests/Users/ListUsersRequest.cs b/PMDDesktop/Requests/Users/ListUsersRequest.cs new file mode 100644 index 0000000..2e3e7d3 --- /dev/null +++ b/PMDDesktop/Requests/Users/ListUsersRequest.cs @@ -0,0 +1,7 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/list")] +public sealed class ListUsersRequest : ServerRequest +{ + +} diff --git a/PMDDesktop/Requests/Users/ListUsersResponse.cs b/PMDDesktop/Requests/Users/ListUsersResponse.cs new file mode 100644 index 0000000..fa84038 --- /dev/null +++ b/PMDDesktop/Requests/Users/ListUsersResponse.cs @@ -0,0 +1,11 @@ +namespace PMDDesktop.Requests.Users; + +public sealed class ListUsersResponse : ServerResponse +{ + + /// + /// All currently existing users, presented in an arbitrary order. + /// + public required UserInfoResponse[] Users { get; set; } + +} diff --git a/PMDDesktop/Requests/Users/SetUserLoginHandleRequest.cs b/PMDDesktop/Requests/Users/SetUserLoginHandleRequest.cs new file mode 100644 index 0000000..e0a55d9 --- /dev/null +++ b/PMDDesktop/Requests/Users/SetUserLoginHandleRequest.cs @@ -0,0 +1,19 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/set-handle")] +public sealed class SetUserLoginHandleRequest : ServerRequest +{ + + /// + /// The of the user you're trying to set the login handle of. + /// + public required Guid GUID { get; set; } + + /// + /// The login handle for the account. + /// Can be null to unset the handle (to prevent logins with handle/password). + /// TEMP: handles must be lowercase, 0-9, or hyphen (-). + /// + public required string? NewLoginHandle { get; set; } + +} diff --git a/PMDDesktop/Requests/Users/SetUserNameRequest.cs b/PMDDesktop/Requests/Users/SetUserNameRequest.cs new file mode 100644 index 0000000..0fc1737 --- /dev/null +++ b/PMDDesktop/Requests/Users/SetUserNameRequest.cs @@ -0,0 +1,11 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/set-name")] +public sealed class SetUserNameRequest : ServerRequest +{ + + public required Guid GUID { get; set; } + + public required string NewName { get; set; } + +} diff --git a/PMDDesktop/Requests/Users/SetUserPasswordRequest.cs b/PMDDesktop/Requests/Users/SetUserPasswordRequest.cs new file mode 100644 index 0000000..f8b061c --- /dev/null +++ b/PMDDesktop/Requests/Users/SetUserPasswordRequest.cs @@ -0,0 +1,18 @@ +namespace PMDDesktop.Requests.Users; + +[ServerRequest("/api/users/set-password")] +public sealed class SetUserPasswordRequest : ServerRequest +{ + + /// + /// The of the user you're trying to set the password of. + /// + public required Guid GUID { get; set; } + + /// + /// The new password for the account (plain text; hashed server-side). + /// Can be null to unset the password (to prevent logins with handle/password). + /// + public required string? NewPassword { get; set; } + +} diff --git a/PMDDesktop/Requests/Users/UserInfoResponse.cs b/PMDDesktop/Requests/Users/UserInfoResponse.cs new file mode 100644 index 0000000..a143260 --- /dev/null +++ b/PMDDesktop/Requests/Users/UserInfoResponse.cs @@ -0,0 +1,11 @@ +namespace PMDDesktop.Requests.Users; + +public sealed class UserInfoResponse : ServerResponse +{ + + public required string DisplayName { get; set; } + public required Guid GUID { get; set; } + public string? LoginHandle { get; set; } + public bool? HasPassword { get; set; } + +}