mirror of
				https://github.com/chylex/Minecraft-Phantom-Panel.git
				synced 2025-10-31 20:17:16 +01:00 
			
		
		
		
	Compare commits
	
		
			3 Commits
		
	
	
		
			0a29b6f21e
			...
			a6bdc6db12
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a6bdc6db12 | |||
| b2c16279c4 | |||
| f1fa90e4d8 | 
| @@ -0,0 +1,12 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|    | ||||
|   <PropertyGroup> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="MemoryPack" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
| </Project> | ||||
| @@ -1,28 +1,25 @@ | ||||
| using System.Collections.Immutable; | ||||
| 
 | ||||
| namespace Phantom.Controller.Services.Users; | ||||
| namespace Phantom.Common.Data.Web.Users; | ||||
| 
 | ||||
| public abstract record AddUserError { | ||||
| 	private AddUserError() {} | ||||
| 
 | ||||
| 	public sealed record NameIsEmpty : AddUserError; | ||||
| 
 | ||||
| 	public sealed record NameIsTooLong(int MaximumLength) : AddUserError; | ||||
| 
 | ||||
| 	public sealed record NameAlreadyExists : AddUserError; | ||||
| 	public sealed record NameIsInvalid(UsernameRequirementViolation Violation) : AddUserError; | ||||
| 
 | ||||
| 	public sealed record PasswordIsInvalid(ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError; | ||||
| 
 | ||||
| 	public sealed record NameAlreadyExists : AddUserError; | ||||
| 
 | ||||
| 	public sealed record UnknownError : AddUserError; | ||||
| } | ||||
| 
 | ||||
| public static class AddUserErrorExtensions { | ||||
| 	public static string ToSentences(this AddUserError error, string delimiter) { | ||||
| 		return error switch { | ||||
| 			AddUserError.NameIsEmpty         => "Name cannot be empty.", | ||||
| 			AddUserError.NameIsTooLong e     => "Name cannot be longer than " + e.MaximumLength + " character(s).", | ||||
| 			AddUserError.NameAlreadyExists   => "Name is already occupied.", | ||||
| 			AddUserError.NameIsInvalid e     => e.Violation.ToSentence(), | ||||
| 			AddUserError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())), | ||||
| 			AddUserError.NameAlreadyExists   => "Username is already occupied.", | ||||
| 			_                                => "Unknown error." | ||||
| 		}; | ||||
| 	} | ||||
| @@ -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; | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| namespace Phantom.Controller.Services.Users; | ||||
| namespace Phantom.Common.Data.Web.Users; | ||||
| 
 | ||||
| public abstract record PasswordRequirementViolation { | ||||
| 	private PasswordRequirementViolation() {} | ||||
| @@ -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) { | ||||
| 	private static readonly List<Permission> AllPermissions = new (); | ||||
							
								
								
									
										9
									
								
								Common/Phantom.Common.Data.Web/Users/RoleInfo.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Common/Phantom.Common.Data.Web/Users/RoleInfo.cs
									
									
									
									
									
										Normal 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 | ||||
| ); | ||||
| @@ -1,6 +1,6 @@ | ||||
| using System.Collections.Immutable; | ||||
| 
 | ||||
| namespace Phantom.Controller.Services.Users; | ||||
| namespace Phantom.Common.Data.Web.Users; | ||||
| 
 | ||||
| public abstract record SetUserPasswordError { | ||||
| 	private SetUserPasswordError() {} | ||||
							
								
								
									
										9
									
								
								Common/Phantom.Common.Data.Web/Users/UserInfo.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Common/Phantom.Common.Data.Web/Users/UserInfo.cs
									
									
									
									
									
										Normal 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 | ||||
| ); | ||||
| @@ -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." | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
| @@ -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; | ||||
|  | ||||
| namespace Phantom.Common.Messages.Web;  | ||||
|  | ||||
| public interface IMessageToControllerListener { | ||||
| 	Task<ICreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message); | ||||
| 	Task<NoReply> HandleReply(ReplyMessage message); | ||||
| } | ||||
|   | ||||
| @@ -8,6 +8,7 @@ | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\Phantom.Common.Logging\Phantom.Common.Logging.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" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   | ||||
| @@ -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); | ||||
| 	} | ||||
| } | ||||
| @@ -1,19 +1,21 @@ | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Common.Messages.Web.BiDirectional; | ||||
| using Phantom.Common.Messages.Web.ToController; | ||||
| using Phantom.Utils.Rpc.Message; | ||||
|  | ||||
| namespace Phantom.Common.Messages.Web;  | ||||
|  | ||||
| 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<IMessageToWebListener> ToWeb { get; } = new (PhantomLogger.Create("MessageRegistry", nameof(ToWeb))); | ||||
| 	 | ||||
| 	public static IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener> Definitions { get; } = new MessageDefinitions(); | ||||
|  | ||||
| 	static WebMessageRegistries() { | ||||
| 		ToWeb.Add<ReplyMessage>(127); | ||||
| 		 | ||||
| 		ToController.Add<CreateOrUpdateAdministratorUser>(1); | ||||
| 		ToController.Add<ReplyMessage>(127); | ||||
| 		 | ||||
| 		ToWeb.Add<ReplyMessage>(127); | ||||
| 	} | ||||
|  | ||||
| 	private sealed class MessageDefinitions : IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener> { | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
|  | ||||
| namespace Phantom.Controller.Database.Entities;  | ||||
|  | ||||
| @@ -16,4 +17,8 @@ public sealed class UserEntity { | ||||
| 		Name = name; | ||||
| 		PasswordHash = null!; | ||||
| 	} | ||||
| 	 | ||||
| 	public UserInfo ToUserInfo() { | ||||
| 		return new UserInfo(UserGuid, Name); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -15,6 +15,7 @@ | ||||
|    | ||||
|   <ItemGroup> | ||||
|     <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" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| using Microsoft.AspNetCore.Components.Authorization; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Controller.Database.Enums; | ||||
| @@ -10,13 +9,11 @@ namespace Phantom.Controller.Services.Audit; | ||||
|  | ||||
| public sealed partial class AuditLog { | ||||
| 	private readonly IDatabaseProvider databaseProvider; | ||||
| 	private readonly AuthenticationStateProvider authenticationStateProvider; | ||||
| 	private readonly TaskManager taskManager; | ||||
| 	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.authenticationStateProvider = authenticationStateProvider; | ||||
| 		this.taskManager = taskManager; | ||||
| 		this.cancellationToken = cancellationToken; | ||||
| 	} | ||||
|   | ||||
| @@ -6,6 +6,7 @@ using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Minecraft; | ||||
| using Phantom.Controller.Rpc; | ||||
| using Phantom.Controller.Services.Agents; | ||||
| using Phantom.Controller.Services.Audit; | ||||
| using Phantom.Controller.Services.Events; | ||||
| using Phantom.Controller.Services.Instances; | ||||
| using Phantom.Controller.Services.Rpc; | ||||
| @@ -19,7 +20,9 @@ namespace Phantom.Controller.Services; | ||||
| public sealed class ControllerServices { | ||||
| 	private TaskManager TaskManager { get; } | ||||
| 	private MinecraftVersions MinecraftVersions { get; } | ||||
| 	 | ||||
|  | ||||
| 	private AuditLog AuditLog { get; } | ||||
|  | ||||
| 	private AgentManager AgentManager { get; } | ||||
| 	private AgentJavaRuntimesManager AgentJavaRuntimesManager { get; } | ||||
| 	private EventLog EventLog { get; } | ||||
| @@ -38,13 +41,15 @@ public sealed class ControllerServices { | ||||
| 		this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, ControllerServices>()); | ||||
| 		this.MinecraftVersions = new MinecraftVersions(); | ||||
| 		 | ||||
| 		this.AuditLog = new AuditLog(databaseProvider, TaskManager, shutdownCancellationToken); | ||||
| 		 | ||||
| 		this.AgentManager = new AgentManager(agentAuthToken, databaseProvider, TaskManager, shutdownCancellationToken); | ||||
| 		this.AgentJavaRuntimesManager = new AgentJavaRuntimesManager(); | ||||
| 		this.EventLog = new EventLog(databaseProvider, TaskManager, shutdownCancellationToken); | ||||
| 		this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, databaseProvider, shutdownCancellationToken); | ||||
| 		this.InstanceLogManager = new InstanceLogManager(); | ||||
| 		 | ||||
| 		this.UserManager = new UserManager(databaseProvider); | ||||
| 		this.UserManager = new UserManager(databaseProvider, AuditLog); | ||||
| 		this.RoleManager = new RoleManager(databaseProvider); | ||||
| 		this.UserRoleManager = new UserRoleManager(databaseProvider); | ||||
| 		this.PermissionManager = new PermissionManager(databaseProvider); | ||||
| @@ -58,7 +63,7 @@ public sealed class ControllerServices { | ||||
| 	} | ||||
|  | ||||
| 	public WebMessageListener CreateWebMessageListener(RpcClientConnection<IMessageToWebListener> connection) { | ||||
| 		return new WebMessageListener(connection); | ||||
| 		return new WebMessageListener(connection, AuditLog, UserManager, RoleManager, UserRoleManager); | ||||
| 	} | ||||
|  | ||||
| 	public async Task Initialize() { | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|    | ||||
|   <PropertyGroup> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|   | ||||
| @@ -1,15 +1,64 @@ | ||||
| using Phantom.Common.Messages.Web; | ||||
| using Phantom.Common.Messages.Web.BiDirectional; | ||||
| using Phantom.Common.Messages.Web.ToController; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| 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.Tasks; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Rpc;  | ||||
|  | ||||
| public sealed class WebMessageListener : IMessageToControllerListener { | ||||
| 	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.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) { | ||||
|   | ||||
| @@ -5,7 +5,7 @@ using Phantom.Common.Logging; | ||||
| using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Utils.Collections; | ||||
| using ILogger = Serilog.ILogger; | ||||
| using Serilog; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Users.Permissions; | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Phantom.Controller.Services.Users.Permissions; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Users.Roles; | ||||
|  | ||||
|   | ||||
| @@ -4,7 +4,7 @@ using Phantom.Common.Logging; | ||||
| using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Utils.Collections; | ||||
| using ILogger = Serilog.ILogger; | ||||
| using Serilog; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Users.Roles; | ||||
|  | ||||
| @@ -49,11 +49,13 @@ public sealed class UserRoleManager { | ||||
| 			await using var ctx = databaseProvider.Provide(); | ||||
| 			 | ||||
| 			var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); | ||||
| 			if (userRole == null) { | ||||
| 				userRole = new UserRoleEntity(user.UserGuid, role.RoleGuid); | ||||
| 				ctx.UserRoles.Add(userRole); | ||||
| 				await ctx.SaveChangesAsync(); | ||||
| 			if (userRole != null) { | ||||
| 				return true; | ||||
| 			} | ||||
| 			 | ||||
| 			userRole = new UserRoleEntity(user.UserGuid, role.RoleGuid); | ||||
| 			ctx.UserRoles.Add(userRole); | ||||
| 			await ctx.SaveChangesAsync(); | ||||
| 		} 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); | ||||
| 			return false; | ||||
| @@ -68,10 +70,12 @@ public sealed class UserRoleManager { | ||||
| 			await using var ctx = databaseProvider.Provide(); | ||||
| 			 | ||||
| 			var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); | ||||
| 			if (userRole != null) { | ||||
| 				ctx.UserRoles.Remove(userRole); | ||||
| 				await ctx.SaveChangesAsync(); | ||||
| 			if (userRole == null) { | ||||
| 				return true; | ||||
| 			} | ||||
|  | ||||
| 			ctx.UserRoles.Remove(userRole); | ||||
| 			await ctx.SaveChangesAsync(); | ||||
| 		} 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); | ||||
| 			return false; | ||||
|   | ||||
| @@ -1,25 +1,26 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Security.Claims; | ||||
| using Microsoft.AspNetCore.Identity; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Controller.Database; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| using Phantom.Controller.Services.Audit; | ||||
| using Phantom.Controller.Services.Users.Roles; | ||||
| using Phantom.Utils.Collections; | ||||
| using Phantom.Utils.Tasks; | ||||
| using ILogger = Serilog.ILogger; | ||||
| using Serilog; | ||||
|  | ||||
| namespace Phantom.Controller.Services.Users; | ||||
|  | ||||
| public sealed class UserManager { | ||||
| sealed class UserManager { | ||||
| 	private static readonly ILogger Logger = PhantomLogger.Create<UserManager>(); | ||||
|  | ||||
| 	private const int MaxUserNameLength = 40; | ||||
|  | ||||
| 	private readonly IDatabaseProvider databaseProvider; | ||||
| 	private readonly AuditLog auditLog; | ||||
|  | ||||
| 	public UserManager(IDatabaseProvider databaseProvider) { | ||||
| 	public UserManager(IDatabaseProvider databaseProvider, AuditLog auditLog) { | ||||
| 		this.databaseProvider = databaseProvider; | ||||
| 		this.auditLog = auditLog; | ||||
| 	} | ||||
|  | ||||
| 	public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) { | ||||
| @@ -57,10 +58,10 @@ public sealed class UserManager { | ||||
| 			return null; | ||||
| 		} | ||||
|  | ||||
| 		switch (UserPasswords.Verify(user, password)) { | ||||
| 		switch (UserValidation.VerifyPassword(user, password)) { | ||||
| 			case PasswordVerificationResult.SuccessRehashNeeded: | ||||
| 				try { | ||||
| 					UserPasswords.Set(user, password); | ||||
| 					UserValidation.SetPassword(user, password); | ||||
| 					await ctx.SaveChangesAsync(); | ||||
| 				} catch (Exception e) { | ||||
| 					Logger.Warning(e, "Could not rehash password for \"{Username}\".", user.Name); | ||||
| @@ -78,62 +79,74 @@ public sealed class UserManager { | ||||
| 		throw new InvalidOperationException(); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) { | ||||
| 		if (string.IsNullOrWhiteSpace(username)) { | ||||
| 			return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameIsEmpty()); | ||||
| 		} | ||||
| 		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()); | ||||
| 	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); | ||||
| 		} | ||||
| 		 | ||||
| 		Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, newUser.UserGuid); | ||||
| 		return Result.Ok<UserEntity, AddUserError>(newUser); | ||||
| 		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) { | ||||
| 		await using var editor = new Editor(databaseProvider); | ||||
| 		return await editor.CreateUser(username, password); | ||||
| 	} | ||||
|  | ||||
| 	public async Task<Result<SetUserPasswordError>> SetUserPassword(Guid guid, string password) { | ||||
| 		UserEntity foundUser; | ||||
| 		 | ||||
|  | ||||
| 		await using (var ctx = databaseProvider.Provide()) { | ||||
| 			var user = await ctx.Users.FindAsync(guid); | ||||
| 			if (user == null) { | ||||
| 				return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound()); | ||||
| 				return new SetUserPasswordError.UserNotFound(); | ||||
| 			} | ||||
|  | ||||
| 			foundUser = user; | ||||
| 			try { | ||||
| 				var requirementViolations = UserPasswords.CheckRequirements(password); | ||||
| 				var requirementViolations = UserValidation.CheckPasswordRequirements(password); | ||||
| 				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(); | ||||
| 			} catch (Exception e) { | ||||
| 				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,19 +155,73 @@ public sealed class UserManager { | ||||
| 	} | ||||
|  | ||||
| 	public async Task<DeleteUserResult> DeleteByGuid(Guid guid) { | ||||
| 		await using var ctx = databaseProvider.Provide(); | ||||
| 		var user = await ctx.Users.FindAsync(guid); | ||||
| 		if (user == null) { | ||||
| 			return DeleteUserResult.NotFound; | ||||
| 		await using var editor = new Editor(databaseProvider); | ||||
| 		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; | ||||
| 		} | ||||
|  | ||||
| 		try { | ||||
| 			ctx.Users.Remove(user); | ||||
| 			await ctx.SaveChangesAsync(); | ||||
| 			return DeleteUserResult.Deleted; | ||||
| 		} catch (Exception e) { | ||||
| 			Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid); | ||||
| 			return DeleteUserResult.Failed; | ||||
| 		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) { | ||||
| 				return DeleteUserResult.NotFound; | ||||
| 			} | ||||
|  | ||||
| 			try { | ||||
| 				Ctx.Users.Remove(user); | ||||
| 				await Ctx.SaveChangesAsync(); | ||||
| 				return DeleteUserResult.Deleted; | ||||
| 			} catch (Exception e) { | ||||
| 				Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid); | ||||
| 				return DeleteUserResult.Failed; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,19 +1,31 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.AspNetCore.Identity; | ||||
| using Phantom.Controller.Database.Entities; | ||||
| 
 | ||||
| namespace Phantom.Controller.Services.Users; | ||||
| 
 | ||||
| static class UserPasswords { | ||||
| static class UserValidation { | ||||
| 	private static PasswordHasher<UserEntity> Hasher { get; } = new (); | ||||
| 
 | ||||
| 	private const int MinimumLength = 16; | ||||
| 	 | ||||
| 	public static ImmutableArray<PasswordRequirementViolation> CheckRequirements(string password) { | ||||
| 	private const int MaxUserNameLength = 40; | ||||
| 	private const int MinimumPasswordLength = 16; | ||||
| 
 | ||||
| 	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>(); | ||||
| 		 | ||||
| 		if (password.Length < MinimumLength) { | ||||
| 			violations.Add(new PasswordRequirementViolation.TooShort(MinimumLength)); | ||||
| 		if (password.Length < MinimumPasswordLength) { | ||||
| 			violations.Add(new PasswordRequirementViolation.TooShort(MinimumPasswordLength)); | ||||
| 		} | ||||
| 
 | ||||
| 		if (!password.Any(char.IsLower)) { | ||||
| @@ -31,11 +43,11 @@ static class UserPasswords { | ||||
| 		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); | ||||
| 	} | ||||
| 	 | ||||
| 	public static PasswordVerificationResult Verify(UserEntity user, string password) { | ||||
| 
 | ||||
| 	public static PasswordVerificationResult VerifyPassword(UserEntity user, string password) { | ||||
| 		return Hasher.VerifyHashedPassword(user, user.PasswordHash, password); | ||||
| 	} | ||||
| } | ||||
| @@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data", "Comm | ||||
| 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}" | ||||
| 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}" | ||||
| 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}" | ||||
| @@ -58,7 +60,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Bootstrap", "We | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Components", "Web\Phantom.Web.Components\Phantom.Web.Components.csproj", "{3F4F9059-F869-42D3-B92C-90D27ADFC42D}" | ||||
| 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 | ||||
| Global | ||||
| 	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}.Release|Any CPU.ActiveCfg = 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.Build.0 = Debug|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}.Release|Any CPU.ActiveCfg = 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 | ||||
| 		{A9870842-FE7A-4760-95DC-9D485DDDA31F}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{A9870842-FE7A-4760-95DC-9D485DDDA31F}.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}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{7B0EEE34-A586-4629-AC51-16757DE53261}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{7B0EEE34-A586-4629-AC51-16757DE53261}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{7B0EEE34-A586-4629-AC51-16757DE53261}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(NestedProjects) = preSolution | ||||
| 		{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} | ||||
| 		{6E798DEB-8921-41A2-8AFB-E4416A9E0704} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} | ||||
| 		{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} | ||||
| 		{E3AD566F-384A-489A-A3BB-EA3BA400C18C} = {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} | ||||
| 		{83FA86DB-34E4-4C2C-832C-90F491CA10C7} = {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 | ||||
| EndGlobal | ||||
|   | ||||
| @@ -2,15 +2,44 @@ | ||||
|  | ||||
| public abstract record Result<TValue, TError> { | ||||
| 	private Result() {} | ||||
|  | ||||
| 	public abstract TValue Value { get; init; } | ||||
| 	public abstract TError Error { get; init; } | ||||
|  | ||||
| 	public static implicit operator Result<TValue, TError>(TValue value) { | ||||
| 		return new Ok(value); | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed record Ok(TValue Value) : Result<TValue, TError>; | ||||
| 	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 Fail(TError Error) : Result<TValue, TError>; | ||||
| 	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> { | ||||
| 	private Result() {} | ||||
|  | ||||
| 	public static implicit operator Result<TError>(TError error) { | ||||
| 		return new Fail(error); | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed record Ok : Result<TError> { | ||||
| 		internal static Ok Instance { get; } = new (); | ||||
| 	} | ||||
|   | ||||
| @@ -1,6 +0,0 @@ | ||||
| namespace Phantom.Web.Identity.Interfaces;  | ||||
|  | ||||
| public interface ILoginEvents { | ||||
| 	void UserLoggedIn(UserEntity user); | ||||
| 	void UserLoggedOut(Guid userGuid); | ||||
| } | ||||
| @@ -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> | ||||
| @@ -1,7 +1,6 @@ | ||||
| using System.Security.Claims; | ||||
| using Microsoft.AspNetCore.Authentication; | ||||
| using Microsoft.AspNetCore.Authentication.Cookies; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Utils.Cryptography; | ||||
| using Phantom.Web.Identity.Interfaces; | ||||
| using ILogger = Serilog.ILogger; | ||||
| @@ -1,6 +1,5 @@ | ||||
| using System.Collections.Concurrent; | ||||
| using System.Diagnostics; | ||||
| using Phantom.Common.Logging; | ||||
| using Phantom.Utils.Tasks; | ||||
| using ILogger = Serilog.ILogger; | ||||
| 
 | ||||
| @@ -1,4 +1,5 @@ | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Phantom.Web.Services.Authorization; | ||||
| 
 | ||||
| namespace Phantom.Web.Identity.Authorization; | ||||
| 
 | ||||
| @@ -1,5 +1,6 @@ | ||||
| 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; | ||||
							
								
								
									
										11
									
								
								Web/Phantom.Web.Services/Authorization/PermissionManager.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								Web/Phantom.Web.Services/Authorization/PermissionManager.cs
									
									
									
									
									
										Normal 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; | ||||
| 	} | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| @using Microsoft.AspNetCore.Components.Authorization | ||||
| @using Phantom.Web.Identity.Data | ||||
| @using Phantom.Common.Data.Web.Users.Permissions | ||||
| @inject PermissionManager PermissionManager | ||||
| 
 | ||||
| <AuthorizeView> | ||||
							
								
								
									
										17
									
								
								Web/Phantom.Web.Services/Phantom.Web.Services.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								Web/Phantom.Web.Services/Phantom.Web.Services.csproj
									
									
									
									
									
										Normal 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> | ||||
| @@ -2,13 +2,16 @@ | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Components.Authorization; | ||||
| 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.Authorization; | ||||
| using Phantom.Web.Services.Authorization; | ||||
| 
 | ||||
| namespace Phantom.Web.Identity; | ||||
| namespace Phantom.Web.Services;  | ||||
| 
 | ||||
| public static class PhantomIdentityExtensions { | ||||
| 	public static void AddPhantomIdentity(this IServiceCollection services, CancellationToken cancellationToken) { | ||||
| public static class PhantomWebServices { | ||||
| 	public static void AddPhantomServices(this IServiceCollection services, CancellationToken cancellationToken) { | ||||
| 		services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(ConfigureIdentityCookie); | ||||
| 		services.AddAuthorization(ConfigureAuthorization); | ||||
| 
 | ||||
| @@ -18,8 +21,8 @@ public static class PhantomIdentityExtensions { | ||||
| 		services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>(); | ||||
| 		services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>(); | ||||
| 	} | ||||
| 
 | ||||
| 	public static void UsePhantomIdentity(this IApplicationBuilder application) { | ||||
| 	 | ||||
| 	public static void UsePhantomServices(this IApplicationBuilder application) { | ||||
| 		application.UseAuthentication(); | ||||
| 		application.UseAuthorization(); | ||||
| 		application.UseWhen(PhantomIdentityMiddleware.AcceptsPath, static app => app.UseMiddleware<PhantomIdentityMiddleware>()); | ||||
| @@ -37,7 +40,7 @@ public static class PhantomIdentityExtensions { | ||||
| 		o.LogoutPath = PhantomIdentityMiddleware.LogoutPath; | ||||
| 		o.AccessDeniedPath = PhantomIdentityMiddleware.LoginPath; | ||||
| 	} | ||||
| 
 | ||||
| 	 | ||||
| 	private static void ConfigureAuthorization(AuthorizationOptions o) { | ||||
| 		foreach (var permission in Permission.All) { | ||||
| 			o.AddPolicy(permission.Id, policy => policy.Requirements.Add(new PermissionBasedPolicyRequirement(permission))); | ||||
							
								
								
									
										5
									
								
								Web/Phantom.Web.Services/Users/UserManager.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								Web/Phantom.Web.Services/Users/UserManager.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| namespace Phantom.Web.Services.Users;  | ||||
|  | ||||
| public sealed class UserManager { | ||||
| 	 | ||||
| } | ||||
| @@ -1,6 +1,4 @@ | ||||
| using Phantom.Web.Identity.Interfaces; | ||||
|  | ||||
| namespace Phantom.Web.Base; | ||||
| namespace Phantom.Web.Base; | ||||
|  | ||||
| sealed class LoginEvents : ILoginEvents { | ||||
| 	private readonly AuditLog auditLog; | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| using Microsoft.AspNetCore.Components; | ||||
| using Microsoft.AspNetCore.Components.Authorization; | ||||
| using Phantom.Common.Data.Web.Users.Permissions; | ||||
| using Phantom.Common.Logging; | ||||
| using ILogger = Serilog.ILogger; | ||||
|  | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| using Microsoft.AspNetCore.DataProtection; | ||||
| using Phantom.Utils.Tasks; | ||||
| using Phantom.Web.Base; | ||||
| using Phantom.Web.Identity; | ||||
| using Phantom.Web.Identity.Interfaces; | ||||
| using Phantom.Web.Services; | ||||
| using Serilog; | ||||
|  | ||||
| namespace Phantom.Web; | ||||
|  | ||||
| public static class Launcher { | ||||
| static class Launcher { | ||||
| 	public static WebApplication CreateApplication(Configuration config, ServiceConfiguration serviceConfiguration, TaskManager taskManager) { | ||||
| 		var assembly = typeof(Launcher).Assembly; | ||||
| 		var builder = WebApplication.CreateBuilder(new WebApplicationOptions { | ||||
| @@ -26,15 +26,13 @@ public static class Launcher { | ||||
|  | ||||
| 		builder.Services.AddSingleton(serviceConfiguration); | ||||
| 		builder.Services.AddSingleton(taskManager); | ||||
| 		builder.Services.AddPhantomServices(config.CancellationToken); | ||||
|  | ||||
| 		builder.Services.AddSingleton<IHostLifetime>(new NullLifetime()); | ||||
| 		builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath)); | ||||
|  | ||||
| 		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.AddServerSideBlazor(); | ||||
|  | ||||
| @@ -53,7 +51,7 @@ public static class Launcher { | ||||
|  | ||||
| 		application.UseStaticFiles(); | ||||
| 		application.UseRouting(); | ||||
| 		application.UsePhantomIdentity(); | ||||
| 		application.UsePhantomServices(); | ||||
|  | ||||
| 		application.MapControllers(); | ||||
| 		application.MapBlazorHub(); | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| @inject ServiceConfiguration Configuration | ||||
| @using Phantom.Common.Data.Web.Users.Permissions | ||||
| @inject ServiceConfiguration Configuration | ||||
| @inject PermissionManager PermissionManager | ||||
|  | ||||
| <div class="navbar navbar-dark"> | ||||
|   | ||||
| @@ -1,14 +1,17 @@ | ||||
| @page "/setup" | ||||
| @using Phantom.Common.Data.Web.Users | ||||
| @using Phantom.Utils.Cryptography | ||||
| @using Phantom.Utils.Tasks | ||||
| @using Phantom.Web.Identity.Authentication | ||||
| @using Phantom.Web.Services.Users | ||||
| @using Microsoft.AspNetCore.Identity | ||||
| @using System.ComponentModel.DataAnnotations | ||||
| @using Phantom.Utils.Cryptography | ||||
| @using System.Security.Cryptography | ||||
| @attribute [AllowAnonymous] | ||||
| @inject ServiceConfiguration ServiceConfiguration | ||||
| @inject PhantomLoginManager LoginManager | ||||
| @inject UserManager UserManager | ||||
| @inject RoleManager RoleManager | ||||
| @inject RoleManager<> RoleManager | ||||
| @inject UserRoleManager UserRoleManager | ||||
| @inject AuditLog AuditLog | ||||
|  | ||||
| @@ -97,7 +100,7 @@ | ||||
|     } | ||||
|  | ||||
|     switch (await UserManager.CreateUser(form.Username, form.Password)) { | ||||
|       case Result<UserEntity, AddUserError>.Ok ok: | ||||
|       case Result<UserInfo, AddUserError>.Ok ok: | ||||
|         var administratorUser = ok.Value; | ||||
|         await AuditLog.AddAdministratorUserCreatedEvent(administratorUser); | ||||
|  | ||||
| @@ -107,15 +110,15 @@ | ||||
|  | ||||
|         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("Unknown error."); | ||||
|   } | ||||
|  | ||||
|   private async Task<Result<string>> UpdateAdministrator(UserEntity existingUser) { | ||||
|     switch (await UserManager.SetUserPassword(existingUser.UserGuid, form.Password)) { | ||||
|   private async Task<Result<string>> UpdateAdministrator(UserInfo existingUser) { | ||||
|     switch (await UserManager.SetUserPassword(existingUser.Guid, form.Password)) { | ||||
|       case Result<SetUserPasswordError>.Ok: | ||||
|         await AuditLog.AddAdministratorUserModifiedEvent(existingUser); | ||||
|         return Result.Ok<string>(); | ||||
|   | ||||
| @@ -20,8 +20,10 @@ | ||||
|   </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.Identity\Phantom.Web.Identity.csproj" /> | ||||
|     <ProjectReference Include="..\Phantom.Web.Services\Phantom.Web.Services.csproj" /> | ||||
|   </ItemGroup> | ||||
|    | ||||
| </Project> | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| @using Phantom.Controller.Services.Instances | ||||
| @using Phantom.Controller.Services.Audit | ||||
| @using Phantom.Common.Data.Web.Users.Permissions | ||||
| @using Phantom.Common.Data.Replies | ||||
| @inherits PhantomComponent | ||||
| @inject InstanceManager InstanceManager | ||||
|   | ||||
| @@ -1,8 +1,7 @@ | ||||
| @inherits PhantomComponent | ||||
| @using Phantom.Utils.Collections | ||||
| @using Phantom.Utils.Events | ||||
| @using System.Diagnostics | ||||
| @using Phantom.Controller.Services.Instances | ||||
| @using Phantom.Common.Data.Web.Users.Permissions | ||||
| @implements IDisposable | ||||
| @inject IJSRuntime Js; | ||||
| @inject InstanceLogManager InstanceLogManager | ||||
|   | ||||
| @@ -1,7 +1,5 @@ | ||||
| @using Phantom.Controller.Services.Users | ||||
| @using Phantom.Common.Data.Web.Users | ||||
| @using Phantom.Utils.Tasks | ||||
| @using Phantom.Controller.Database.Entities | ||||
| @using Phantom.Controller.Services.Audit | ||||
| @using System.ComponentModel.DataAnnotations | ||||
| @inherits PhantomComponent | ||||
| @inject IJSRuntime Js; | ||||
|   | ||||
| @@ -1,6 +1,4 @@ | ||||
| @using Phantom.Controller.Database.Entities | ||||
| @using Phantom.Controller.Services.Audit | ||||
| @using Phantom.Controller.Services.Users | ||||
| @using Phantom.Common.Data.Web.Users | ||||
| @inherits UserEditDialogBase | ||||
| @inject UserManager UserManager | ||||
| @inject AuditLog AuditLog | ||||
| @@ -19,8 +17,8 @@ | ||||
|  | ||||
| @code { | ||||
|  | ||||
|   protected override async Task DoEdit(UserEntity user) { | ||||
|     switch (await UserManager.DeleteByGuid(user.UserGuid)) { | ||||
|   protected override async Task DoEdit(UserInfo user) { | ||||
|     switch (await UserManager.DeleteByGuid(user.Guid)) { | ||||
|       case DeleteUserResult.Deleted: | ||||
|         await AuditLog.AddUserDeletedEvent(user); | ||||
|         await OnEditSuccess(); | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| using Microsoft.AspNetCore.Components; | ||||
| using Microsoft.JSInterop; | ||||
| using Phantom.Common.Data.Web.Users; | ||||
| using Phantom.Common.Data.Web.Users.Permissions; | ||||
| using Phantom.Web.Base; | ||||
| using Phantom.Web.Components.Forms; | ||||
|  | ||||
| @@ -13,14 +15,14 @@ public abstract class UserEditDialogBase : PhantomComponent { | ||||
| 	public string ModalId { get; set; } = string.Empty; | ||||
|  | ||||
| 	[Parameter] | ||||
| 	public EventCallback<UserEntity> UserModified { get; set; } | ||||
| 	public EventCallback<UserInfo> UserModified { get; set; } | ||||
|  | ||||
| 	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; | ||||
|  | ||||
| 	internal async Task Show(UserEntity user) { | ||||
| 	internal async Task Show(UserInfo user) { | ||||
| 		EditedUser = user; | ||||
| 		EditedUserName = user.Name; | ||||
| 		await BeforeShown(user); | ||||
| @@ -29,7 +31,7 @@ public abstract class UserEditDialogBase : PhantomComponent { | ||||
| 		await Js.InvokeVoidAsync("showModal", ModalId); | ||||
| 	} | ||||
|  | ||||
| 	protected virtual Task BeforeShown(UserEntity user) { | ||||
| 	protected virtual Task BeforeShown(UserInfo user) { | ||||
| 		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() { | ||||
| 		await UserModified.InvokeAsync(EditedUser); | ||||
|   | ||||
| @@ -1,11 +1,5 @@ | ||||
| @using Phantom.Controller.Database.Entities | ||||
| @using Phantom.Controller.Services.Audit | ||||
| @using Phantom.Controller.Services.Users | ||||
| @using Phantom.Common.Data.Web.Users | ||||
| @inherits UserEditDialogBase | ||||
| @inject UserManager UserManager | ||||
| @inject RoleManager RoleManager | ||||
| @inject UserRoleManager UserRoleManager | ||||
| @inject AuditLog AuditLog | ||||
|  | ||||
| <Modal Id="@ModalId" TitleText="Manage User Roles"> | ||||
|   <Body> | ||||
| @@ -29,13 +23,13 @@ | ||||
|  | ||||
|   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 allRoles = await RoleManager.GetAll(); | ||||
|     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 addedToRoles = new List<string>(); | ||||
|     var removedFromRoles = new List<string>(); | ||||
| @@ -43,7 +37,7 @@ | ||||
|  | ||||
|     foreach (var item in items) { | ||||
|       var shouldHaveRole = item.Checked; | ||||
|       if (shouldHaveRole == userRoles.Contains(item.Role.RoleGuid)) { | ||||
|       if (shouldHaveRole == userRoles.Contains(item.Role.Guid)) { | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
| @@ -70,10 +64,10 @@ | ||||
|   } | ||||
|  | ||||
|   private sealed class RoleItem { | ||||
|     public RoleEntity Role { get; } | ||||
|     public RoleInfo Role { get; } | ||||
|     public bool Checked { get; set; } | ||||
|  | ||||
|     public RoleItem(RoleEntity role, bool @checked) { | ||||
|     public RoleItem(RoleInfo role, bool @checked) { | ||||
|       this.Role = role; | ||||
|       this.Checked = @checked; | ||||
|     } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user