1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2024-10-18 15:42:50 +02:00

Compare commits

..

3 Commits

Author SHA1 Message Date
a6bdc6db12
WIP 2023-10-21 07:16:53 +02:00
b2c16279c4
WIP 2023-10-21 07:16:53 +02:00
f1fa90e4d8
Fully separate Controller and Web into their own services - Controller compiling and setup 2023-10-21 07:16:53 +02:00
51 changed files with 458 additions and 198 deletions

View File

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MemoryPack" />
</ItemGroup>
</Project>

View File

@ -1,28 +1,25 @@
using System.Collections.Immutable; using System.Collections.Immutable;
namespace Phantom.Controller.Services.Users; namespace Phantom.Common.Data.Web.Users;
public abstract record AddUserError { public abstract record AddUserError {
private AddUserError() {} private AddUserError() {}
public sealed record NameIsEmpty : AddUserError; public sealed record NameIsInvalid(UsernameRequirementViolation Violation) : AddUserError;
public sealed record NameIsTooLong(int MaximumLength) : AddUserError;
public sealed record NameAlreadyExists : AddUserError;
public sealed record PasswordIsInvalid(ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError; public sealed record PasswordIsInvalid(ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError;
public sealed record NameAlreadyExists : AddUserError;
public sealed record UnknownError : AddUserError; public sealed record UnknownError : AddUserError;
} }
public static class AddUserErrorExtensions { public static class AddUserErrorExtensions {
public static string ToSentences(this AddUserError error, string delimiter) { public static string ToSentences(this AddUserError error, string delimiter) {
return error switch { return error switch {
AddUserError.NameIsEmpty => "Name cannot be empty.", AddUserError.NameIsInvalid e => e.Violation.ToSentence(),
AddUserError.NameIsTooLong e => "Name cannot be longer than " + e.MaximumLength + " character(s).",
AddUserError.NameAlreadyExists => "Name is already occupied.",
AddUserError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())), AddUserError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
AddUserError.NameAlreadyExists => "Username is already occupied.",
_ => "Unknown error." _ => "Unknown error."
}; };
} }

View File

@ -0,0 +1,22 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.Users;
[MemoryPackable]
[MemoryPackUnion(0, typeof(Success))]
[MemoryPackUnion(1, typeof(CreationFailed))]
[MemoryPackUnion(2, typeof(UpdatingFailed))]
[MemoryPackUnion(3, typeof(AddingToRoleFailed))]
public partial interface ICreateOrUpdateAdministratorUserResult {
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Success(UserInfo User) : ICreateOrUpdateAdministratorUserResult;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record CreationFailed(AddUserError Error) : ICreateOrUpdateAdministratorUserResult;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record UpdatingFailed(SetUserPasswordError Error) : ICreateOrUpdateAdministratorUserResult;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AddingToRoleFailed : ICreateOrUpdateAdministratorUserResult;
}

View File

@ -1,4 +1,4 @@
namespace Phantom.Controller.Services.Users; namespace Phantom.Common.Data.Web.Users;
public abstract record PasswordRequirementViolation { public abstract record PasswordRequirementViolation {
private PasswordRequirementViolation() {} private PasswordRequirementViolation() {}

View File

@ -1,4 +1,4 @@
namespace Phantom.Controller.Services.Users.Permissions; namespace Phantom.Common.Data.Web.Users.Permissions;
public sealed record Permission(string Id, Permission? Parent) { public sealed record Permission(string Id, Permission? Parent) {
private static readonly List<Permission> AllPermissions = new (); private static readonly List<Permission> AllPermissions = new ();

View File

@ -0,0 +1,9 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.Users;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record RoleInfo(
[property: MemoryPackOrder(0)] Guid Guid,
[property: MemoryPackOrder(1)] string Name
);

View File

@ -1,6 +1,6 @@
using System.Collections.Immutable; using System.Collections.Immutable;
namespace Phantom.Controller.Services.Users; namespace Phantom.Common.Data.Web.Users;
public abstract record SetUserPasswordError { public abstract record SetUserPasswordError {
private SetUserPasswordError() {} private SetUserPasswordError() {}

View File

@ -0,0 +1,9 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.Users;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record UserInfo(
[property: MemoryPackOrder(0)] Guid Guid,
[property: MemoryPackOrder(1)] string Name
);

View File

@ -0,0 +1,19 @@
namespace Phantom.Common.Data.Web.Users;
public abstract record UsernameRequirementViolation {
private UsernameRequirementViolation() {}
public sealed record IsEmpty : UsernameRequirementViolation;
public sealed record TooLong(int MaxLength) : UsernameRequirementViolation;
}
public static class UsernameRequirementViolationExtensions {
public static string ToSentence(this UsernameRequirementViolation violation) {
return violation switch {
UsernameRequirementViolation.IsEmpty => "Username must not be empty.",
UsernameRequirementViolation.TooLong v => "Username must not be longer than " + v.MaxLength + " character(s).",
_ => "Unknown error."
};
}
}

View File

@ -1,8 +1,11 @@
using Phantom.Common.Messages.Web.BiDirectional; using Phantom.Common.Data.Web.Users;
using Phantom.Common.Messages.Web.BiDirectional;
using Phantom.Common.Messages.Web.ToController;
using Phantom.Utils.Rpc.Message; using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.Web; namespace Phantom.Common.Messages.Web;
public interface IMessageToControllerListener { public interface IMessageToControllerListener {
Task<ICreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message);
Task<NoReply> HandleReply(ReplyMessage message); Task<NoReply> HandleReply(ReplyMessage message);
} }

View File

@ -8,6 +8,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> <ProjectReference Include="..\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
<ProjectReference Include="..\Phantom.Common.Data\Phantom.Common.Data.csproj" /> <ProjectReference Include="..\Phantom.Common.Data\Phantom.Common.Data.csproj" />
<ProjectReference Include="..\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Rpc\Phantom.Utils.Rpc.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Rpc\Phantom.Utils.Rpc.csproj" />
</ItemGroup> </ItemGroup>

View File

@ -0,0 +1,14 @@
using MemoryPack;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record CreateOrUpdateAdministratorUser(
[property: MemoryPackOrder(0)] string Username,
[property: MemoryPackOrder(1)] string Password
) : IMessageToController {
public Task<NoReply> Accept(IMessageToControllerListener listener) {
return listener.CreateOrUpdateAdministratorUser(this);
}
}

View File

@ -1,19 +1,21 @@
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Common.Messages.Web.BiDirectional; using Phantom.Common.Messages.Web.BiDirectional;
using Phantom.Common.Messages.Web.ToController;
using Phantom.Utils.Rpc.Message; using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.Web; namespace Phantom.Common.Messages.Web;
public static class WebMessageRegistries { public static class WebMessageRegistries {
public static MessageRegistry<IMessageToWebListener> ToWeb { get; } = new (PhantomLogger.Create("MessageRegistry", nameof(ToWeb)));
public static MessageRegistry<IMessageToControllerListener> ToController { get; } = new (PhantomLogger.Create("MessageRegistry", nameof(ToController))); public static MessageRegistry<IMessageToControllerListener> ToController { get; } = new (PhantomLogger.Create("MessageRegistry", nameof(ToController)));
public static MessageRegistry<IMessageToWebListener> ToWeb { get; } = new (PhantomLogger.Create("MessageRegistry", nameof(ToWeb)));
public static IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener> Definitions { get; } = new MessageDefinitions(); public static IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener> Definitions { get; } = new MessageDefinitions();
static WebMessageRegistries() { static WebMessageRegistries() {
ToWeb.Add<ReplyMessage>(127); ToController.Add<CreateOrUpdateAdministratorUser>(1);
ToController.Add<ReplyMessage>(127); ToController.Add<ReplyMessage>(127);
ToWeb.Add<ReplyMessage>(127);
} }
private sealed class MessageDefinitions : IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener> { private sealed class MessageDefinitions : IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener> {

View File

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Phantom.Common.Data.Web.Users;
namespace Phantom.Controller.Database.Entities; namespace Phantom.Controller.Database.Entities;
@ -16,4 +17,8 @@ public sealed class UserEntity {
Name = name; Name = name;
PasswordHash = null!; PasswordHash = null!;
} }
public UserInfo ToUserInfo() {
return new UserInfo(UserGuid, Name);
}
} }

View File

@ -15,6 +15,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> <ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
</ItemGroup> </ItemGroup>

View File

@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
using Phantom.Controller.Database.Enums; using Phantom.Controller.Database.Enums;
@ -10,13 +9,11 @@ namespace Phantom.Controller.Services.Audit;
public sealed partial class AuditLog { public sealed partial class AuditLog {
private readonly IDatabaseProvider databaseProvider; private readonly IDatabaseProvider databaseProvider;
private readonly AuthenticationStateProvider authenticationStateProvider;
private readonly TaskManager taskManager; private readonly TaskManager taskManager;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
public AuditLog(IDatabaseProvider databaseProvider, AuthenticationStateProvider authenticationStateProvider, TaskManager taskManager, CancellationToken cancellationToken) { public AuditLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
this.databaseProvider = databaseProvider; this.databaseProvider = databaseProvider;
this.authenticationStateProvider = authenticationStateProvider;
this.taskManager = taskManager; this.taskManager = taskManager;
this.cancellationToken = cancellationToken; this.cancellationToken = cancellationToken;
} }

View File

@ -6,6 +6,7 @@ using Phantom.Controller.Database;
using Phantom.Controller.Minecraft; using Phantom.Controller.Minecraft;
using Phantom.Controller.Rpc; using Phantom.Controller.Rpc;
using Phantom.Controller.Services.Agents; using Phantom.Controller.Services.Agents;
using Phantom.Controller.Services.Audit;
using Phantom.Controller.Services.Events; using Phantom.Controller.Services.Events;
using Phantom.Controller.Services.Instances; using Phantom.Controller.Services.Instances;
using Phantom.Controller.Services.Rpc; using Phantom.Controller.Services.Rpc;
@ -20,6 +21,8 @@ public sealed class ControllerServices {
private TaskManager TaskManager { get; } private TaskManager TaskManager { get; }
private MinecraftVersions MinecraftVersions { get; } private MinecraftVersions MinecraftVersions { get; }
private AuditLog AuditLog { get; }
private AgentManager AgentManager { get; } private AgentManager AgentManager { get; }
private AgentJavaRuntimesManager AgentJavaRuntimesManager { get; } private AgentJavaRuntimesManager AgentJavaRuntimesManager { get; }
private EventLog EventLog { get; } private EventLog EventLog { get; }
@ -38,13 +41,15 @@ public sealed class ControllerServices {
this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, ControllerServices>()); this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, ControllerServices>());
this.MinecraftVersions = new MinecraftVersions(); this.MinecraftVersions = new MinecraftVersions();
this.AuditLog = new AuditLog(databaseProvider, TaskManager, shutdownCancellationToken);
this.AgentManager = new AgentManager(agentAuthToken, databaseProvider, TaskManager, shutdownCancellationToken); this.AgentManager = new AgentManager(agentAuthToken, databaseProvider, TaskManager, shutdownCancellationToken);
this.AgentJavaRuntimesManager = new AgentJavaRuntimesManager(); this.AgentJavaRuntimesManager = new AgentJavaRuntimesManager();
this.EventLog = new EventLog(databaseProvider, TaskManager, shutdownCancellationToken); this.EventLog = new EventLog(databaseProvider, TaskManager, shutdownCancellationToken);
this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, databaseProvider, shutdownCancellationToken); this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, databaseProvider, shutdownCancellationToken);
this.InstanceLogManager = new InstanceLogManager(); this.InstanceLogManager = new InstanceLogManager();
this.UserManager = new UserManager(databaseProvider); this.UserManager = new UserManager(databaseProvider, AuditLog);
this.RoleManager = new RoleManager(databaseProvider); this.RoleManager = new RoleManager(databaseProvider);
this.UserRoleManager = new UserRoleManager(databaseProvider); this.UserRoleManager = new UserRoleManager(databaseProvider);
this.PermissionManager = new PermissionManager(databaseProvider); this.PermissionManager = new PermissionManager(databaseProvider);
@ -58,7 +63,7 @@ public sealed class ControllerServices {
} }
public WebMessageListener CreateWebMessageListener(RpcClientConnection<IMessageToWebListener> connection) { public WebMessageListener CreateWebMessageListener(RpcClientConnection<IMessageToWebListener> connection) {
return new WebMessageListener(connection); return new WebMessageListener(connection, AuditLog, UserManager, RoleManager, UserRoleManager);
} }
public async Task Initialize() { public async Task Initialize() {

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@ -1,15 +1,64 @@
using Phantom.Common.Messages.Web; using Phantom.Common.Messages.Web;
using Phantom.Common.Messages.Web.BiDirectional; using Phantom.Common.Messages.Web.BiDirectional;
using Phantom.Common.Messages.Web.ToController;
using Phantom.Controller.Database.Entities;
using Phantom.Controller.Rpc; using Phantom.Controller.Rpc;
using Phantom.Controller.Services.Audit;
using Phantom.Controller.Services.Users;
using Phantom.Controller.Services.Users.Roles;
using Phantom.Utils.Rpc.Message; using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Tasks;
namespace Phantom.Controller.Services.Rpc; namespace Phantom.Controller.Services.Rpc;
public sealed class WebMessageListener : IMessageToControllerListener { public sealed class WebMessageListener : IMessageToControllerListener {
private readonly RpcClientConnection<IMessageToWebListener> connection; private readonly RpcClientConnection<IMessageToWebListener> connection;
private readonly AuditLog auditLog;
private readonly UserManager userManager;
private readonly RoleManager roleManager;
private readonly UserRoleManager userRoleManager;
internal WebMessageListener(RpcClientConnection<IMessageToWebListener> connection) { internal WebMessageListener(RpcClientConnection<IMessageToWebListener> connection, AuditLog auditLog, UserManager userManager, RoleManager roleManager, UserRoleManager userRoleManager) {
this.connection = connection; this.connection = connection;
this.auditLog = auditLog;
this.userManager = userManager;
this.roleManager = roleManager;
this.userRoleManager = userRoleManager;
}
public async Task<ICreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message) {
UserEntity administratorUser = null!;
var existingUser = await userManager.GetByName(message.Username);
if (existingUser == null) {
var result = await userManager.CreateUser(message.Username, message.Password);
switch (result) {
case Result<UserEntity, AddUserError>.Ok ok:
administratorUser = ok.Value;
await auditLog.AddAdministratorUserCreatedEvent(administratorUser);
break;
case Result<UserEntity, AddUserError>.Fail fail:
return new ICreateOrUpdateAdministratorUserResult.CreationFailed(fail.Error);
}
}
else {
var result = await userManager.SetUserPassword(existingUser.UserGuid, message.Password);
if (result is Result<SetUserPasswordError>.Fail fail) {
return new ICreateOrUpdateAdministratorUserResult.UpdatingFailed(fail.Error);
}
else {
administratorUser = existingUser;
await auditLog.AddAdministratorUserModifiedEvent(administratorUser);
}
}
var administratorRole = await roleManager.GetByGuid(Role.Administrator.Guid);
if (administratorRole == null || !await userRoleManager.Add(administratorUser, administratorRole)) {
return new ICreateOrUpdateAdministratorUserResult.AddingToRoleFailed();
}
return new ICreateOrUpdateAdministratorUserResult.Success(administratorUser.ToUserInfo());
} }
public Task<NoReply> HandleReply(ReplyMessage message) { public Task<NoReply> HandleReply(ReplyMessage message) {

View File

@ -5,7 +5,7 @@ using Phantom.Common.Logging;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using ILogger = Serilog.ILogger; using Serilog;
namespace Phantom.Controller.Services.Users.Permissions; namespace Phantom.Controller.Services.Users.Permissions;

View File

@ -1,5 +1,4 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Controller.Services.Users.Permissions;
namespace Phantom.Controller.Services.Users.Roles; namespace Phantom.Controller.Services.Users.Roles;

View File

@ -4,7 +4,7 @@ using Phantom.Common.Logging;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using ILogger = Serilog.ILogger; using Serilog;
namespace Phantom.Controller.Services.Users.Roles; namespace Phantom.Controller.Services.Users.Roles;
@ -49,11 +49,13 @@ public sealed class UserRoleManager {
await using var ctx = databaseProvider.Provide(); await using var ctx = databaseProvider.Provide();
var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
if (userRole == null) { if (userRole != null) {
return true;
}
userRole = new UserRoleEntity(user.UserGuid, role.RoleGuid); userRole = new UserRoleEntity(user.UserGuid, role.RoleGuid);
ctx.UserRoles.Add(userRole); ctx.UserRoles.Add(userRole);
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
}
} catch (Exception e) { } catch (Exception e) {
Logger.Error(e, "Could not add user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); Logger.Error(e, "Could not add user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
return false; return false;
@ -68,10 +70,12 @@ public sealed class UserRoleManager {
await using var ctx = databaseProvider.Provide(); await using var ctx = databaseProvider.Provide();
var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
if (userRole != null) { if (userRole == null) {
return true;
}
ctx.UserRoles.Remove(userRole); ctx.UserRoles.Remove(userRole);
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
}
} catch (Exception e) { } catch (Exception e) {
Logger.Error(e, "Could not remove user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); Logger.Error(e, "Could not remove user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
return false; return false;

View File

@ -1,25 +1,26 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
using Phantom.Controller.Services.Audit;
using Phantom.Controller.Services.Users.Roles;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using Phantom.Utils.Tasks; using Phantom.Utils.Tasks;
using ILogger = Serilog.ILogger; using Serilog;
namespace Phantom.Controller.Services.Users; namespace Phantom.Controller.Services.Users;
public sealed class UserManager { sealed class UserManager {
private static readonly ILogger Logger = PhantomLogger.Create<UserManager>(); private static readonly ILogger Logger = PhantomLogger.Create<UserManager>();
private const int MaxUserNameLength = 40;
private readonly IDatabaseProvider databaseProvider; private readonly IDatabaseProvider databaseProvider;
private readonly AuditLog auditLog;
public UserManager(IDatabaseProvider databaseProvider) { public UserManager(IDatabaseProvider databaseProvider, AuditLog auditLog) {
this.databaseProvider = databaseProvider; this.databaseProvider = databaseProvider;
this.auditLog = auditLog;
} }
public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) { public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) {
@ -57,10 +58,10 @@ public sealed class UserManager {
return null; return null;
} }
switch (UserPasswords.Verify(user, password)) { switch (UserValidation.VerifyPassword(user, password)) {
case PasswordVerificationResult.SuccessRehashNeeded: case PasswordVerificationResult.SuccessRehashNeeded:
try { try {
UserPasswords.Set(user, password); UserValidation.SetPassword(user, password);
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
} catch (Exception e) { } catch (Exception e) {
Logger.Warning(e, "Could not rehash password for \"{Username}\".", user.Name); Logger.Warning(e, "Could not rehash password for \"{Username}\".", user.Name);
@ -78,39 +79,51 @@ public sealed class UserManager {
throw new InvalidOperationException(); throw new InvalidOperationException();
} }
public async Task<ICreateOrUpdateAdministratorUserResult> CreateAdministratorUser(string username, string password) {
await using var editor = new Editor(databaseProvider);
var createUserResult = await editor.CreateUser(username, password);
if (!createUserResult) {
return new ICreateOrUpdateAdministratorUserResult.CreationFailed(createUserResult.Error);
}
UserEntity administratorUser = null!;
var existingUser = await userManager.GetByName(message.Username);
if (existingUser == null) {
var result = await userManager.CreateUser(message.Username, message.Password);
switch (result) {
case Result<UserEntity, AddUserError>.Ok ok:
administratorUser = ok.Value;
await auditLog.AddAdministratorUserCreatedEvent(administratorUser);
break;
case Result<UserEntity, AddUserError>.Fail fail:
return new ICreateOrUpdateAdministratorUserResult.CreationFailed(fail.Error);
}
}
else {
var result = await userManager.SetUserPassword(existingUser.UserGuid, message.Password);
if (result is Result<SetUserPasswordError>.Fail fail) {
return new ICreateOrUpdateAdministratorUserResult.UpdatingFailed(fail.Error);
}
else {
administratorUser = existingUser;
await auditLog.AddAdministratorUserModifiedEvent(administratorUser);
}
}
var administratorRole = await roleManager.GetByGuid(Role.Administrator.Guid);
if (administratorRole == null || !await userRoleManager.Add(administratorUser, administratorRole)) {
return new ICreateOrUpdateAdministratorUserResult.AddingToRoleFailed();
}
return new ICreateOrUpdateAdministratorUserResult.Success(administratorUser.ToUserInfo());
}
public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) { public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) {
if (string.IsNullOrWhiteSpace(username)) { await using var editor = new Editor(databaseProvider);
return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameIsEmpty()); return await editor.CreateUser(username, password);
}
else if (username.Length > MaxUserNameLength) {
return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameIsTooLong(MaxUserNameLength));
}
var requirementViolations = UserPasswords.CheckRequirements(password);
if (!requirementViolations.IsEmpty) {
return Result.Fail<UserEntity, AddUserError>(new AddUserError.PasswordIsInvalid(requirementViolations));
}
UserEntity newUser;
try {
await using var ctx = databaseProvider.Provide();
if (await ctx.Users.AnyAsync(user => user.Name == username)) {
return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameAlreadyExists());
}
newUser = new UserEntity(Guid.NewGuid(), username);
UserPasswords.Set(newUser, password);
ctx.Users.Add(newUser);
await ctx.SaveChangesAsync();
} catch (Exception e) {
Logger.Error(e, "Could not create user \"{Name}\".", username);
return Result.Fail<UserEntity, AddUserError>(new AddUserError.UnknownError());
}
Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, newUser.UserGuid);
return Result.Ok<UserEntity, AddUserError>(newUser);
} }
public async Task<Result<SetUserPasswordError>> SetUserPassword(Guid guid, string password) { public async Task<Result<SetUserPasswordError>> SetUserPassword(Guid guid, string password) {
@ -119,21 +132,21 @@ public sealed class UserManager {
await using (var ctx = databaseProvider.Provide()) { await using (var ctx = databaseProvider.Provide()) {
var user = await ctx.Users.FindAsync(guid); var user = await ctx.Users.FindAsync(guid);
if (user == null) { if (user == null) {
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound()); return new SetUserPasswordError.UserNotFound();
} }
foundUser = user; foundUser = user;
try { try {
var requirementViolations = UserPasswords.CheckRequirements(password); var requirementViolations = UserValidation.CheckPasswordRequirements(password);
if (!requirementViolations.IsEmpty) { if (!requirementViolations.IsEmpty) {
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations)); return new SetUserPasswordError.PasswordIsInvalid(requirementViolations);
} }
UserPasswords.Set(user, password); UserValidation.SetPassword(user, password);
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
} catch (Exception e) { } catch (Exception e) {
Logger.Error(e, "Could not change password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid); Logger.Error(e, "Could not change password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UnknownError()); return new SetUserPasswordError.UnknownError();
} }
} }
@ -142,15 +155,68 @@ public sealed class UserManager {
} }
public async Task<DeleteUserResult> DeleteByGuid(Guid guid) { public async Task<DeleteUserResult> DeleteByGuid(Guid guid) {
await using var ctx = databaseProvider.Provide(); await using var editor = new Editor(databaseProvider);
var user = await ctx.Users.FindAsync(guid); return await editor.DeleteUserByGuid(guid);
}
private sealed class Editor : IAsyncDisposable {
public ApplicationDbContext Ctx => cachedContext ??= databaseProvider.Provide();
private readonly IDatabaseProvider databaseProvider;
private ApplicationDbContext? cachedContext;
public Editor(IDatabaseProvider databaseProvider) {
this.databaseProvider = databaseProvider;
}
public ValueTask DisposeAsync() {
return cachedContext?.DisposeAsync() ?? ValueTask.CompletedTask;
}
public Task<UserEntity?> GetByName(string username) {
return Ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
}
public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) {
var usernameRequirementViolation = UserValidation.CheckUsernameRequirements(username);
if (usernameRequirementViolation != null) {
return new AddUserError.NameIsInvalid(usernameRequirementViolation);
}
var passwordRequirementViolations = UserValidation.CheckPasswordRequirements(password);
if (!passwordRequirementViolations.IsEmpty) {
return new AddUserError.PasswordIsInvalid(passwordRequirementViolations);
}
UserEntity newUser;
try {
if (await Ctx.Users.AnyAsync(user => user.Name == username)) {
return new AddUserError.NameAlreadyExists();
}
newUser = new UserEntity(Guid.NewGuid(), username);
UserValidation.SetPassword(newUser, password);
Ctx.Users.Add(newUser);
await Ctx.SaveChangesAsync();
} catch (Exception e) {
Logger.Error(e, "Could not create user \"{Name}\".", username);
return new AddUserError.UnknownError();
}
Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, newUser.UserGuid);
return newUser;
}
public async Task<DeleteUserResult> DeleteUserByGuid(Guid guid) {
var user = await Ctx.Users.FindAsync(guid);
if (user == null) { if (user == null) {
return DeleteUserResult.NotFound; return DeleteUserResult.NotFound;
} }
try { try {
ctx.Users.Remove(user); Ctx.Users.Remove(user);
await ctx.SaveChangesAsync(); await Ctx.SaveChangesAsync();
return DeleteUserResult.Deleted; return DeleteUserResult.Deleted;
} catch (Exception e) { } catch (Exception e) {
Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid); Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
@ -158,3 +224,4 @@ public sealed class UserManager {
} }
} }
} }
}

View File

@ -1,19 +1,31 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Microsoft.AspNetCore.Identity;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
namespace Phantom.Controller.Services.Users; namespace Phantom.Controller.Services.Users;
static class UserPasswords { static class UserValidation {
private static PasswordHasher<UserEntity> Hasher { get; } = new (); private static PasswordHasher<UserEntity> Hasher { get; } = new ();
private const int MinimumLength = 16; private const int MaxUserNameLength = 40;
private const int MinimumPasswordLength = 16;
public static ImmutableArray<PasswordRequirementViolation> CheckRequirements(string password) { public static UsernameRequirementViolation? CheckUsernameRequirements(string username) {
if (string.IsNullOrWhiteSpace(username)) {
return new UsernameRequirementViolation.IsEmpty();
}
else if (username.Length > MaxUserNameLength) {
return new UsernameRequirementViolation.TooLong(MaxUserNameLength);
}
else {
return null;
}
}
public static ImmutableArray<PasswordRequirementViolation> CheckPasswordRequirements(string password) {
var violations = ImmutableArray.CreateBuilder<PasswordRequirementViolation>(); var violations = ImmutableArray.CreateBuilder<PasswordRequirementViolation>();
if (password.Length < MinimumLength) { if (password.Length < MinimumPasswordLength) {
violations.Add(new PasswordRequirementViolation.TooShort(MinimumLength)); violations.Add(new PasswordRequirementViolation.TooShort(MinimumPasswordLength));
} }
if (!password.Any(char.IsLower)) { if (!password.Any(char.IsLower)) {
@ -31,11 +43,11 @@ static class UserPasswords {
return violations.ToImmutable(); return violations.ToImmutable();
} }
public static void Set(UserEntity user, string password) { public static void SetPassword(UserEntity user, string password) {
user.PasswordHash = Hasher.HashPassword(user, password); user.PasswordHash = Hasher.HashPassword(user, password);
} }
public static PasswordVerificationResult Verify(UserEntity user, string password) { public static PasswordVerificationResult VerifyPassword(UserEntity user, string password) {
return Hasher.VerifyHashedPassword(user, user.PasswordHash, password); return Hasher.VerifyHashedPassword(user, user.PasswordHash, password);
} }
} }

View File

@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data", "Comm
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data.Tests", "Common\Phantom.Common.Data.Tests\Phantom.Common.Data.Tests.csproj", "{435D7981-DFDA-46A0-8CD8-CD8C117935D7}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data.Tests", "Common\Phantom.Common.Data.Tests\Phantom.Common.Data.Tests.csproj", "{435D7981-DFDA-46A0-8CD8-CD8C117935D7}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data.Web", "Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj", "{BC969D0B-0019-48E0-9FAF-F5CC906AAF09}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Logging", "Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj", "{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Logging", "Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj", "{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Messages.Agent", "Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj", "{95B55357-F8F0-48C2-A1C2-5EA997651783}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Messages.Agent", "Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj", "{95B55357-F8F0-48C2-A1C2-5EA997651783}"
@ -58,7 +60,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Bootstrap", "We
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Components", "Web\Phantom.Web.Components\Phantom.Web.Components.csproj", "{3F4F9059-F869-42D3-B92C-90D27ADFC42D}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Components", "Web\Phantom.Web.Components\Phantom.Web.Components.csproj", "{3F4F9059-F869-42D3-B92C-90D27ADFC42D}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Identity", "Web\Phantom.Web.Identity\Phantom.Web.Identity.csproj", "{A9870842-FE7A-4760-95DC-9D485DDDA31F}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Services", "Web\Phantom.Web.Services\Phantom.Web.Services.csproj", "{7B0EEE34-A586-4629-AC51-16757DE53261}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -90,6 +92,10 @@ Global
{435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Debug|Any CPU.Build.0 = Debug|Any CPU {435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Release|Any CPU.ActiveCfg = Release|Any CPU {435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Release|Any CPU.Build.0 = Release|Any CPU {435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Release|Any CPU.Build.0 = Release|Any CPU
{BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Release|Any CPU.Build.0 = Release|Any CPU
{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Debug|Any CPU.Build.0 = Debug|Any CPU {D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Release|Any CPU.ActiveCfg = Release|Any CPU {D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -150,10 +156,10 @@ Global
{3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Debug|Any CPU.Build.0 = Debug|Any CPU {3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Release|Any CPU.ActiveCfg = Release|Any CPU {3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Release|Any CPU.Build.0 = Release|Any CPU {3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Release|Any CPU.Build.0 = Release|Any CPU
{A9870842-FE7A-4760-95DC-9D485DDDA31F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7B0EEE34-A586-4629-AC51-16757DE53261}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9870842-FE7A-4760-95DC-9D485DDDA31F}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B0EEE34-A586-4629-AC51-16757DE53261}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9870842-FE7A-4760-95DC-9D485DDDA31F}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B0EEE34-A586-4629-AC51-16757DE53261}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9870842-FE7A-4760-95DC-9D485DDDA31F}.Release|Any CPU.Build.0 = Release|Any CPU {7B0EEE34-A586-4629-AC51-16757DE53261}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{418BE1BF-9F63-4B46-B4E4-DF64C3B3DDA7} = {F5878792-64C8-4ECF-A075-66341FF97127} {418BE1BF-9F63-4B46-B4E4-DF64C3B3DDA7} = {F5878792-64C8-4ECF-A075-66341FF97127}
@ -165,6 +171,7 @@ Global
{95B55357-F8F0-48C2-A1C2-5EA997651783} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {95B55357-F8F0-48C2-A1C2-5EA997651783} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18}
{6E798DEB-8921-41A2-8AFB-E4416A9E0704} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {6E798DEB-8921-41A2-8AFB-E4416A9E0704} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18}
{435D7981-DFDA-46A0-8CD8-CD8C117935D7} = {D781E00D-8563-4102-A0CD-477A679193B5} {435D7981-DFDA-46A0-8CD8-CD8C117935D7} = {D781E00D-8563-4102-A0CD-477A679193B5}
{BC969D0B-0019-48E0-9FAF-F5CC906AAF09} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18}
{A0F1C595-96B6-4DBF-8C16-6B99223F8F35} = {0AB9471E-6228-4EB7-802E-3102B3952AAD} {A0F1C595-96B6-4DBF-8C16-6B99223F8F35} = {0AB9471E-6228-4EB7-802E-3102B3952AAD}
{E3AD566F-384A-489A-A3BB-EA3BA400C18C} = {0AB9471E-6228-4EB7-802E-3102B3952AAD} {E3AD566F-384A-489A-A3BB-EA3BA400C18C} = {0AB9471E-6228-4EB7-802E-3102B3952AAD}
{81625B4A-3DB6-48BD-A739-D23DA02107D1} = {0AB9471E-6228-4EB7-802E-3102B3952AAD} {81625B4A-3DB6-48BD-A739-D23DA02107D1} = {0AB9471E-6228-4EB7-802E-3102B3952AAD}
@ -178,6 +185,6 @@ Global
{7CA2E5FE-E507-4DC6-930C-E18711A9F856} = {92B26F48-235F-4500-BD55-800F06A0BA39} {7CA2E5FE-E507-4DC6-930C-E18711A9F856} = {92B26F48-235F-4500-BD55-800F06A0BA39}
{83FA86DB-34E4-4C2C-832C-90F491CA10C7} = {92B26F48-235F-4500-BD55-800F06A0BA39} {83FA86DB-34E4-4C2C-832C-90F491CA10C7} = {92B26F48-235F-4500-BD55-800F06A0BA39}
{3F4F9059-F869-42D3-B92C-90D27ADFC42D} = {92B26F48-235F-4500-BD55-800F06A0BA39} {3F4F9059-F869-42D3-B92C-90D27ADFC42D} = {92B26F48-235F-4500-BD55-800F06A0BA39}
{A9870842-FE7A-4760-95DC-9D485DDDA31F} = {92B26F48-235F-4500-BD55-800F06A0BA39} {7B0EEE34-A586-4629-AC51-16757DE53261} = {92B26F48-235F-4500-BD55-800F06A0BA39}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@ -3,14 +3,43 @@
public abstract record Result<TValue, TError> { public abstract record Result<TValue, TError> {
private Result() {} private Result() {}
public sealed record Ok(TValue Value) : Result<TValue, TError>; public abstract TValue Value { get; init; }
public abstract TError Error { get; init; }
public sealed record Fail(TError Error) : Result<TValue, TError>; public static implicit operator Result<TValue, TError>(TValue value) {
return new Ok(value);
}
public static implicit operator Result<TValue, TError>(TError error) {
return new Fail(error);
}
public static implicit operator bool(Result<TValue, TError> result) {
return result is Ok;
}
public sealed record Ok(TValue Value) : Result<TValue, TError> {
public override TError Error {
get => throw new InvalidOperationException("Attempted to get error from Ok result.");
init {}
}
}
public sealed record Fail(TError Error) : Result<TValue, TError> {
public override TValue Value {
get => throw new InvalidOperationException("Attempted to get value from Fail result.");
init {}
}
}
} }
public abstract record Result<TError> { public abstract record Result<TError> {
private Result() {} private Result() {}
public static implicit operator Result<TError>(TError error) {
return new Fail(error);
}
public sealed record Ok : Result<TError> { public sealed record Ok : Result<TError> {
internal static Ok Instance { get; } = new (); internal static Ok Instance { get; } = new ();
} }

View File

@ -1,6 +0,0 @@
namespace Phantom.Web.Identity.Interfaces;
public interface ILoginEvents {
void UserLoggedIn(UserEntity user);
void UserLoggedOut(Guid userGuid);
}

View File

@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
</ItemGroup>
</Project>

View File

@ -1,7 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Phantom.Common.Logging;
using Phantom.Utils.Cryptography; using Phantom.Utils.Cryptography;
using Phantom.Web.Identity.Interfaces; using Phantom.Web.Identity.Interfaces;
using ILogger = Serilog.ILogger; using ILogger = Serilog.ILogger;

View File

@ -1,6 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using Phantom.Common.Logging;
using Phantom.Utils.Tasks; using Phantom.Utils.Tasks;
using ILogger = Serilog.ILogger; using ILogger = Serilog.ILogger;

View File

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Phantom.Web.Services.Authorization;
namespace Phantom.Web.Identity.Authorization; namespace Phantom.Web.Identity.Authorization;

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Phantom.Common.Data.Web.Users.Permissions;
namespace Phantom.Web.Identity.Authorization; namespace Phantom.Web.Services.Authorization;
sealed record PermissionBasedPolicyRequirement(Permission Permission) : IAuthorizationRequirement; sealed record PermissionBasedPolicyRequirement(Permission Permission) : IAuthorizationRequirement;

View File

@ -0,0 +1,11 @@
using System.Security.Claims;
using Phantom.Common.Data.Web.Users.Permissions;
namespace Phantom.Web.Services.Authorization;
public class PermissionManager {
// TODO
public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) {
return false;
}
}

View File

@ -1,5 +1,5 @@
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using Phantom.Web.Identity.Data @using Phantom.Common.Data.Web.Users.Permissions
@inject PermissionManager PermissionManager @inject PermissionManager PermissionManager
<AuthorizeView> <AuthorizeView>

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Authorization\PermissionView.razor" />
</ItemGroup>
</Project>

View File

@ -2,13 +2,16 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Components.Server;
using Phantom.Common.Data.Web.Users.Permissions;
using Phantom.Web.Identity;
using Phantom.Web.Identity.Authentication; using Phantom.Web.Identity.Authentication;
using Phantom.Web.Identity.Authorization; using Phantom.Web.Identity.Authorization;
using Phantom.Web.Services.Authorization;
namespace Phantom.Web.Identity; namespace Phantom.Web.Services;
public static class PhantomIdentityExtensions { public static class PhantomWebServices {
public static void AddPhantomIdentity(this IServiceCollection services, CancellationToken cancellationToken) { public static void AddPhantomServices(this IServiceCollection services, CancellationToken cancellationToken) {
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(ConfigureIdentityCookie); services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(ConfigureIdentityCookie);
services.AddAuthorization(ConfigureAuthorization); services.AddAuthorization(ConfigureAuthorization);
@ -19,7 +22,7 @@ public static class PhantomIdentityExtensions {
services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>(); services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
} }
public static void UsePhantomIdentity(this IApplicationBuilder application) { public static void UsePhantomServices(this IApplicationBuilder application) {
application.UseAuthentication(); application.UseAuthentication();
application.UseAuthorization(); application.UseAuthorization();
application.UseWhen(PhantomIdentityMiddleware.AcceptsPath, static app => app.UseMiddleware<PhantomIdentityMiddleware>()); application.UseWhen(PhantomIdentityMiddleware.AcceptsPath, static app => app.UseMiddleware<PhantomIdentityMiddleware>());

View File

@ -0,0 +1,5 @@
namespace Phantom.Web.Services.Users;
public sealed class UserManager {
}

View File

@ -1,6 +1,4 @@
using Phantom.Web.Identity.Interfaces; namespace Phantom.Web.Base;
namespace Phantom.Web.Base;
sealed class LoginEvents : ILoginEvents { sealed class LoginEvents : ILoginEvents {
private readonly AuditLog auditLog; private readonly AuditLog auditLog;

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Phantom.Common.Data.Web.Users.Permissions;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using ILogger = Serilog.ILogger; using ILogger = Serilog.ILogger;

View File

@ -1,13 +1,13 @@
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Phantom.Utils.Tasks; using Phantom.Utils.Tasks;
using Phantom.Web.Base; using Phantom.Web.Base;
using Phantom.Web.Identity;
using Phantom.Web.Identity.Interfaces; using Phantom.Web.Identity.Interfaces;
using Phantom.Web.Services;
using Serilog; using Serilog;
namespace Phantom.Web; namespace Phantom.Web;
public static class Launcher { static class Launcher {
public static WebApplication CreateApplication(Configuration config, ServiceConfiguration serviceConfiguration, TaskManager taskManager) { public static WebApplication CreateApplication(Configuration config, ServiceConfiguration serviceConfiguration, TaskManager taskManager) {
var assembly = typeof(Launcher).Assembly; var assembly = typeof(Launcher).Assembly;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { var builder = WebApplication.CreateBuilder(new WebApplicationOptions {
@ -26,15 +26,13 @@ public static class Launcher {
builder.Services.AddSingleton(serviceConfiguration); builder.Services.AddSingleton(serviceConfiguration);
builder.Services.AddSingleton(taskManager); builder.Services.AddSingleton(taskManager);
builder.Services.AddPhantomServices(config.CancellationToken);
builder.Services.AddSingleton<IHostLifetime>(new NullLifetime()); builder.Services.AddSingleton<IHostLifetime>(new NullLifetime());
builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath)); builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath));
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.KeyFolderPath)); builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.KeyFolderPath));
builder.Services.AddPhantomIdentity(config.CancellationToken);
builder.Services.AddScoped<ILoginEvents, LoginEvents>();
builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout"); builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout");
builder.Services.AddServerSideBlazor(); builder.Services.AddServerSideBlazor();
@ -53,7 +51,7 @@ public static class Launcher {
application.UseStaticFiles(); application.UseStaticFiles();
application.UseRouting(); application.UseRouting();
application.UsePhantomIdentity(); application.UsePhantomServices();
application.MapControllers(); application.MapControllers();
application.MapBlazorHub(); application.MapBlazorHub();

View File

@ -1,4 +1,5 @@
@inject ServiceConfiguration Configuration @using Phantom.Common.Data.Web.Users.Permissions
@inject ServiceConfiguration Configuration
@inject PermissionManager PermissionManager @inject PermissionManager PermissionManager
<div class="navbar navbar-dark"> <div class="navbar navbar-dark">

View File

@ -1,14 +1,17 @@
@page "/setup" @page "/setup"
@using Phantom.Common.Data.Web.Users
@using Phantom.Utils.Cryptography
@using Phantom.Utils.Tasks @using Phantom.Utils.Tasks
@using Phantom.Web.Identity.Authentication @using Phantom.Web.Identity.Authentication
@using Phantom.Web.Services.Users
@using Microsoft.AspNetCore.Identity
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@using Phantom.Utils.Cryptography
@using System.Security.Cryptography @using System.Security.Cryptography
@attribute [AllowAnonymous] @attribute [AllowAnonymous]
@inject ServiceConfiguration ServiceConfiguration @inject ServiceConfiguration ServiceConfiguration
@inject PhantomLoginManager LoginManager @inject PhantomLoginManager LoginManager
@inject UserManager UserManager @inject UserManager UserManager
@inject RoleManager RoleManager @inject RoleManager<> RoleManager
@inject UserRoleManager UserRoleManager @inject UserRoleManager UserRoleManager
@inject AuditLog AuditLog @inject AuditLog AuditLog
@ -97,7 +100,7 @@
} }
switch (await UserManager.CreateUser(form.Username, form.Password)) { switch (await UserManager.CreateUser(form.Username, form.Password)) {
case Result<UserEntity, AddUserError>.Ok ok: case Result<UserInfo, AddUserError>.Ok ok:
var administratorUser = ok.Value; var administratorUser = ok.Value;
await AuditLog.AddAdministratorUserCreatedEvent(administratorUser); await AuditLog.AddAdministratorUserCreatedEvent(administratorUser);
@ -107,15 +110,15 @@
return Result.Ok<string>(); return Result.Ok<string>();
case Result<UserEntity, AddUserError>.Fail fail: case Result<UserInfo, AddUserError>.Fail fail:
return Result.Fail(fail.Error.ToSentences("\n")); return Result.Fail(fail.Error.ToSentences("\n"));
} }
return Result.Fail("Unknown error."); return Result.Fail("Unknown error.");
} }
private async Task<Result<string>> UpdateAdministrator(UserEntity existingUser) { private async Task<Result<string>> UpdateAdministrator(UserInfo existingUser) {
switch (await UserManager.SetUserPassword(existingUser.UserGuid, form.Password)) { switch (await UserManager.SetUserPassword(existingUser.Guid, form.Password)) {
case Result<SetUserPasswordError>.Ok: case Result<SetUserPasswordError>.Ok:
await AuditLog.AddAdministratorUserModifiedEvent(existingUser); await AuditLog.AddAdministratorUserModifiedEvent(existingUser);
return Result.Ok<string>(); return Result.Ok<string>();

View File

@ -20,8 +20,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
<ProjectReference Include="..\Phantom.Web.Components\Phantom.Web.Components.csproj" /> <ProjectReference Include="..\Phantom.Web.Components\Phantom.Web.Components.csproj" />
<ProjectReference Include="..\Phantom.Web.Identity\Phantom.Web.Identity.csproj" /> <ProjectReference Include="..\Phantom.Web.Services\Phantom.Web.Services.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,5 +1,4 @@
@using Phantom.Controller.Services.Instances @using Phantom.Common.Data.Web.Users.Permissions
@using Phantom.Controller.Services.Audit
@using Phantom.Common.Data.Replies @using Phantom.Common.Data.Replies
@inherits PhantomComponent @inherits PhantomComponent
@inject InstanceManager InstanceManager @inject InstanceManager InstanceManager

View File

@ -1,8 +1,7 @@
@inherits PhantomComponent @inherits PhantomComponent
@using Phantom.Utils.Collections @using Phantom.Utils.Collections
@using Phantom.Utils.Events
@using System.Diagnostics @using System.Diagnostics
@using Phantom.Controller.Services.Instances @using Phantom.Common.Data.Web.Users.Permissions
@implements IDisposable @implements IDisposable
@inject IJSRuntime Js; @inject IJSRuntime Js;
@inject InstanceLogManager InstanceLogManager @inject InstanceLogManager InstanceLogManager

View File

@ -1,7 +1,5 @@
@using Phantom.Controller.Services.Users @using Phantom.Common.Data.Web.Users
@using Phantom.Utils.Tasks @using Phantom.Utils.Tasks
@using Phantom.Controller.Database.Entities
@using Phantom.Controller.Services.Audit
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@inherits PhantomComponent @inherits PhantomComponent
@inject IJSRuntime Js; @inject IJSRuntime Js;

View File

@ -1,6 +1,4 @@
@using Phantom.Controller.Database.Entities @using Phantom.Common.Data.Web.Users
@using Phantom.Controller.Services.Audit
@using Phantom.Controller.Services.Users
@inherits UserEditDialogBase @inherits UserEditDialogBase
@inject UserManager UserManager @inject UserManager UserManager
@inject AuditLog AuditLog @inject AuditLog AuditLog
@ -19,8 +17,8 @@
@code { @code {
protected override async Task DoEdit(UserEntity user) { protected override async Task DoEdit(UserInfo user) {
switch (await UserManager.DeleteByGuid(user.UserGuid)) { switch (await UserManager.DeleteByGuid(user.Guid)) {
case DeleteUserResult.Deleted: case DeleteUserResult.Deleted:
await AuditLog.AddUserDeletedEvent(user); await AuditLog.AddUserDeletedEvent(user);
await OnEditSuccess(); await OnEditSuccess();

View File

@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using Phantom.Common.Data.Web.Users;
using Phantom.Common.Data.Web.Users.Permissions;
using Phantom.Web.Base; using Phantom.Web.Base;
using Phantom.Web.Components.Forms; using Phantom.Web.Components.Forms;
@ -13,14 +15,14 @@ public abstract class UserEditDialogBase : PhantomComponent {
public string ModalId { get; set; } = string.Empty; public string ModalId { get; set; } = string.Empty;
[Parameter] [Parameter]
public EventCallback<UserEntity> UserModified { get; set; } public EventCallback<UserInfo> UserModified { get; set; }
protected readonly FormButtonSubmit.SubmitModel SubmitModel = new(); protected readonly FormButtonSubmit.SubmitModel SubmitModel = new();
private UserEntity? EditedUser { get; set; } = null; private UserInfo? EditedUser { get; set; } = null;
protected string EditedUserName { get; private set; } = string.Empty; protected string EditedUserName { get; private set; } = string.Empty;
internal async Task Show(UserEntity user) { internal async Task Show(UserInfo user) {
EditedUser = user; EditedUser = user;
EditedUserName = user.Name; EditedUserName = user.Name;
await BeforeShown(user); await BeforeShown(user);
@ -29,7 +31,7 @@ public abstract class UserEditDialogBase : PhantomComponent {
await Js.InvokeVoidAsync("showModal", ModalId); await Js.InvokeVoidAsync("showModal", ModalId);
} }
protected virtual Task BeforeShown(UserEntity user) { protected virtual Task BeforeShown(UserInfo user) {
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -51,7 +53,7 @@ public abstract class UserEditDialogBase : PhantomComponent {
} }
} }
protected abstract Task DoEdit(UserEntity user); protected abstract Task DoEdit(UserInfo user);
protected async Task OnEditSuccess() { protected async Task OnEditSuccess() {
await UserModified.InvokeAsync(EditedUser); await UserModified.InvokeAsync(EditedUser);

View File

@ -1,11 +1,5 @@
@using Phantom.Controller.Database.Entities @using Phantom.Common.Data.Web.Users
@using Phantom.Controller.Services.Audit
@using Phantom.Controller.Services.Users
@inherits UserEditDialogBase @inherits UserEditDialogBase
@inject UserManager UserManager
@inject RoleManager RoleManager
@inject UserRoleManager UserRoleManager
@inject AuditLog AuditLog
<Modal Id="@ModalId" TitleText="Manage User Roles"> <Modal Id="@ModalId" TitleText="Manage User Roles">
<Body> <Body>
@ -29,13 +23,13 @@
private List<RoleItem> items = new(); private List<RoleItem> items = new();
protected override async Task BeforeShown(UserEntity user) { protected override async Task BeforeShown(UserInfo user) {
var userRoles = await UserRoleManager.GetUserRoleGuids(user); var userRoles = await UserRoleManager.GetUserRoleGuids(user);
var allRoles = await RoleManager.GetAll(); var allRoles = await RoleManager.GetAll();
this.items = allRoles.Select(role => new RoleItem(role, userRoles.Contains(role.RoleGuid))).ToList(); this.items = allRoles.Select(role => new RoleItem(role, userRoles.Contains(role.RoleGuid))).ToList();
} }
protected override async Task DoEdit(UserEntity user) { protected override async Task DoEdit(UserInfo user) {
var userRoles = await UserRoleManager.GetUserRoleGuids(user); var userRoles = await UserRoleManager.GetUserRoleGuids(user);
var addedToRoles = new List<string>(); var addedToRoles = new List<string>();
var removedFromRoles = new List<string>(); var removedFromRoles = new List<string>();
@ -43,7 +37,7 @@
foreach (var item in items) { foreach (var item in items) {
var shouldHaveRole = item.Checked; var shouldHaveRole = item.Checked;
if (shouldHaveRole == userRoles.Contains(item.Role.RoleGuid)) { if (shouldHaveRole == userRoles.Contains(item.Role.Guid)) {
continue; continue;
} }
@ -70,10 +64,10 @@
} }
private sealed class RoleItem { private sealed class RoleItem {
public RoleEntity Role { get; } public RoleInfo Role { get; }
public bool Checked { get; set; } public bool Checked { get; set; }
public RoleItem(RoleEntity role, bool @checked) { public RoleItem(RoleInfo role, bool @checked) {
this.Role = role; this.Role = role;
this.Checked = @checked; this.Checked = @checked;
} }