mirror of
https://github.com/chylex/Minecraft-Phantom-Panel.git
synced 2024-11-25 16:42:54 +01:00
Compare commits
2 Commits
0df1546fb6
...
149bb6e0f1
Author | SHA1 | Date | |
---|---|---|---|
149bb6e0f1 | |||
f14d7f5590 |
@ -1,9 +1,10 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<component name="ProjectRunConfigurationManager">
|
||||||
<configuration default="false" name="Controller + Agent x3" type="CompoundRunConfigurationType">
|
<configuration default="false" name="Controller + Web + Agent x3" type="CompoundRunConfigurationType">
|
||||||
<toRun name="Agent 1" type="DotNetProject" />
|
<toRun name="Agent 1" type="DotNetProject" />
|
||||||
<toRun name="Agent 2" type="DotNetProject" />
|
<toRun name="Agent 2" type="DotNetProject" />
|
||||||
<toRun name="Agent 3" type="DotNetProject" />
|
<toRun name="Agent 3" type="DotNetProject" />
|
||||||
<toRun name="Controller" type="DotNetProject" />
|
<toRun name="Controller" type="DotNetProject" />
|
||||||
|
<toRun name="Web" type="DotNetProject" />
|
||||||
<method v="2" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
@ -1,7 +1,8 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<component name="ProjectRunConfigurationManager">
|
||||||
<configuration default="false" name="Controller + Agent" type="CompoundRunConfigurationType">
|
<configuration default="false" name="Controller + Web + Agent" type="CompoundRunConfigurationType">
|
||||||
<toRun name="Agent 1" type="DotNetProject" />
|
<toRun name="Agent 1" type="DotNetProject" />
|
||||||
<toRun name="Controller" type="DotNetProject" />
|
<toRun name="Controller" type="DotNetProject" />
|
||||||
|
<toRun name="Web" type="DotNetProject" />
|
||||||
<method v="2" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
26
.run/Web.run.xml
Normal file
26
.run/Web.run.xml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Web" type="DotNetProject" factoryName=".NET Project">
|
||||||
|
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Web/debug/Phantom.Web.exe" />
|
||||||
|
<option name="PROGRAM_PARAMETERS" value="" />
|
||||||
|
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Web" />
|
||||||
|
<option name="PASS_PARENT_ENVS" value="1" />
|
||||||
|
<envs>
|
||||||
|
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
|
||||||
|
<env name="CONTROLLER_HOST" value="localhost" />
|
||||||
|
<env name="WEB_KEY" value="BMNHM9RRPMCBBY29D9XHS6KBKZSRY7F5XFN27YZX96XXWJC2NM2D6YRHM9PZN9JGQGCSJ6FMB2GGZ" />
|
||||||
|
<env name="WEB_SERVER_HOST" value="localhost" />
|
||||||
|
</envs>
|
||||||
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
|
<option name="USE_MONO" value="0" />
|
||||||
|
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||||
|
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" />
|
||||||
|
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
|
||||||
|
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
|
||||||
|
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
|
||||||
|
<option name="PROJECT_KIND" value="DotNetCore" />
|
||||||
|
<option name="PROJECT_TFM" value="net8.0" />
|
||||||
|
<method v="2">
|
||||||
|
<option name="Build" />
|
||||||
|
</method>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
@ -18,8 +18,4 @@ public sealed class ControllerConnection {
|
|||||||
public Task Send<TMessage>(TMessage message) where TMessage : IMessageToController {
|
public Task Send<TMessage>(TMessage message) where TMessage : IMessageToController {
|
||||||
return connection.Send(message);
|
return connection.Send(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToController<TReply> where TReply : class {
|
|
||||||
return connection.Send<TMessage, TReply>(message, waitForReplyTime, waitForReplyCancellationToken);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
using NetMQ;
|
using NetMQ;
|
||||||
using Phantom.Common.Data.Agent;
|
using Phantom.Common.Data;
|
||||||
using Phantom.Common.Logging;
|
using Phantom.Common.Logging;
|
||||||
using Phantom.Utils.Cryptography;
|
using Phantom.Utils.Cryptography;
|
||||||
using Phantom.Utils.IO;
|
using Phantom.Utils.IO;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
namespace Phantom.Controller.Database.Enums;
|
namespace Phantom.Common.Data.Web.AuditLog;
|
||||||
|
|
||||||
public enum AuditLogEventType {
|
public enum AuditLogEventType {
|
||||||
AdministratorUserCreated,
|
AdministratorUserCreated,
|
||||||
@ -6,6 +6,7 @@ public enum AuditLogEventType {
|
|||||||
UserLoggedIn,
|
UserLoggedIn,
|
||||||
UserLoggedOut,
|
UserLoggedOut,
|
||||||
UserCreated,
|
UserCreated,
|
||||||
|
UserPasswordChanged,
|
||||||
UserRolesChanged,
|
UserRolesChanged,
|
||||||
UserDeleted,
|
UserDeleted,
|
||||||
InstanceCreated,
|
InstanceCreated,
|
||||||
@ -15,13 +16,14 @@ public enum AuditLogEventType {
|
|||||||
InstanceCommandExecuted
|
InstanceCommandExecuted
|
||||||
}
|
}
|
||||||
|
|
||||||
static class AuditLogEventTypeExtensions {
|
public static class AuditLogEventTypeExtensions {
|
||||||
private static readonly Dictionary<AuditLogEventType, AuditLogSubjectType> SubjectTypes = new () {
|
private static readonly Dictionary<AuditLogEventType, AuditLogSubjectType> SubjectTypes = new () {
|
||||||
{ AuditLogEventType.AdministratorUserCreated, AuditLogSubjectType.User },
|
{ AuditLogEventType.AdministratorUserCreated, AuditLogSubjectType.User },
|
||||||
{ AuditLogEventType.AdministratorUserModified, AuditLogSubjectType.User },
|
{ AuditLogEventType.AdministratorUserModified, AuditLogSubjectType.User },
|
||||||
{ AuditLogEventType.UserLoggedIn, AuditLogSubjectType.User },
|
{ AuditLogEventType.UserLoggedIn, AuditLogSubjectType.User },
|
||||||
{ AuditLogEventType.UserLoggedOut, AuditLogSubjectType.User },
|
{ AuditLogEventType.UserLoggedOut, AuditLogSubjectType.User },
|
||||||
{ AuditLogEventType.UserCreated, AuditLogSubjectType.User },
|
{ AuditLogEventType.UserCreated, AuditLogSubjectType.User },
|
||||||
|
{ AuditLogEventType.UserPasswordChanged, AuditLogSubjectType.User },
|
||||||
{ AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User },
|
{ AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User },
|
||||||
{ AuditLogEventType.UserDeleted, AuditLogSubjectType.User },
|
{ AuditLogEventType.UserDeleted, AuditLogSubjectType.User },
|
||||||
{ AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance },
|
{ AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance },
|
||||||
@ -39,7 +41,7 @@ static class AuditLogEventTypeExtensions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static AuditLogSubjectType GetSubjectType(this AuditLogEventType type) {
|
public static AuditLogSubjectType GetSubjectType(this AuditLogEventType type) {
|
||||||
return SubjectTypes[type];
|
return SubjectTypes[type];
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,6 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Phantom.Controller.Database.Enums;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Audit;
|
namespace Phantom.Common.Data.Web.AuditLog;
|
||||||
|
|
||||||
public sealed record AuditLogItem(DateTime UtcTime, Guid? UserGuid, string? UserName, AuditLogEventType EventType, AuditLogSubjectType SubjectType, string? SubjectId, JsonDocument? Data);
|
public sealed record AuditLogItem(DateTime UtcTime, Guid? UserGuid, string? UserName, AuditLogEventType EventType, AuditLogSubjectType SubjectType, string? SubjectId, JsonDocument? Data);
|
@ -1,4 +1,4 @@
|
|||||||
namespace Phantom.Controller.Database.Enums;
|
namespace Phantom.Common.Data.Web.AuditLog;
|
||||||
|
|
||||||
public enum AuditLogSubjectType {
|
public enum AuditLogSubjectType {
|
||||||
User,
|
User,
|
@ -1,4 +1,4 @@
|
|||||||
namespace Phantom.Controller.Database.Enums;
|
namespace Phantom.Common.Data.Web.EventLog;
|
||||||
|
|
||||||
public enum EventLogEventType {
|
public enum EventLogEventType {
|
||||||
InstanceLaunchSucceded,
|
InstanceLaunchSucceded,
|
||||||
@ -10,7 +10,7 @@ public enum EventLogEventType {
|
|||||||
InstanceBackupFailed,
|
InstanceBackupFailed,
|
||||||
}
|
}
|
||||||
|
|
||||||
static class EventLogEventTypeExtensions {
|
public static class EventLogEventTypeExtensions {
|
||||||
private static readonly Dictionary<EventLogEventType, EventLogSubjectType> SubjectTypes = new () {
|
private static readonly Dictionary<EventLogEventType, EventLogSubjectType> SubjectTypes = new () {
|
||||||
{ EventLogEventType.InstanceLaunchSucceded, EventLogSubjectType.Instance },
|
{ EventLogEventType.InstanceLaunchSucceded, EventLogSubjectType.Instance },
|
||||||
{ EventLogEventType.InstanceLaunchFailed, EventLogSubjectType.Instance },
|
{ EventLogEventType.InstanceLaunchFailed, EventLogSubjectType.Instance },
|
@ -1,6 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Phantom.Controller.Database.Enums;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Events;
|
namespace Phantom.Common.Data.Web.EventLog;
|
||||||
|
|
||||||
public sealed record EventLogItem(DateTime UtcTime, Guid? AgentGuid, EventLogEventType EventType, EventLogSubjectType SubjectType, string SubjectId, JsonDocument? Data);
|
public sealed record EventLogItem(DateTime UtcTime, Guid? AgentGuid, EventLogEventType EventType, EventLogSubjectType SubjectType, string SubjectId, JsonDocument? Data);
|
@ -0,0 +1,5 @@
|
|||||||
|
namespace Phantom.Common.Data.Web.EventLog;
|
||||||
|
|
||||||
|
public enum EventLogSubjectType {
|
||||||
|
Instance
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
namespace Phantom.Controller.Minecraft;
|
namespace Phantom.Common.Data.Web.Minecraft;
|
||||||
|
|
||||||
public static class JvmArgumentsHelper {
|
public static class JvmArgumentsHelper {
|
||||||
public static ImmutableArray<string> Split(string arguments) {
|
public static ImmutableArray<string> Split(string arguments) {
|
@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BCrypt.Net-Next.StrongName" />
|
||||||
|
<PackageReference Include="MemoryPack" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
@ -1,4 +1,4 @@
|
|||||||
namespace Phantom.Controller.Services.Users.Roles;
|
namespace Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
public enum AddRoleError : byte {
|
public enum AddRoleError : byte {
|
||||||
NameIsEmpty,
|
NameIsEmpty,
|
28
Common/Phantom.Common.Data.Web/Users/AddUserError.cs
Normal file
28
Common/Phantom.Common.Data.Web/Users/AddUserError.cs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using MemoryPack;
|
||||||
|
using Phantom.Common.Data.Web.Users.AddUserErrors;
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users {
|
||||||
|
[MemoryPackable]
|
||||||
|
[MemoryPackUnion(0, typeof(NameIsInvalid))]
|
||||||
|
[MemoryPackUnion(1, typeof(PasswordIsInvalid))]
|
||||||
|
[MemoryPackUnion(2, typeof(NameAlreadyExists))]
|
||||||
|
[MemoryPackUnion(3, typeof(UnknownError))]
|
||||||
|
public abstract partial record AddUserError {
|
||||||
|
internal AddUserError() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users.AddUserErrors {
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record NameIsInvalid([property: MemoryPackOrder(0)] UsernameRequirementViolation Violation) : AddUserError;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record PasswordIsInvalid([property: MemoryPackOrder(0)] ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record NameAlreadyExists : AddUserError;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record UnknownError : AddUserError;
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
using MemoryPack;
|
||||||
|
using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults;
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users {
|
||||||
|
[MemoryPackable]
|
||||||
|
[MemoryPackUnion(0, typeof(Success))]
|
||||||
|
[MemoryPackUnion(1, typeof(CreationFailed))]
|
||||||
|
[MemoryPackUnion(2, typeof(UpdatingFailed))]
|
||||||
|
[MemoryPackUnion(3, typeof(AddingToRoleFailed))]
|
||||||
|
[MemoryPackUnion(4, typeof(UnknownError))]
|
||||||
|
public abstract partial record CreateOrUpdateAdministratorUserResult {
|
||||||
|
internal CreateOrUpdateAdministratorUserResult() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults {
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record Success([property: MemoryPackOrder(0)] UserInfo User) : CreateOrUpdateAdministratorUserResult;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record CreationFailed([property: MemoryPackOrder(0)] AddUserError Error) : CreateOrUpdateAdministratorUserResult;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record UpdatingFailed([property: MemoryPackOrder(0)] SetUserPasswordError Error) : CreateOrUpdateAdministratorUserResult;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record AddingToRoleFailed : CreateOrUpdateAdministratorUserResult;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record UnknownError : CreateOrUpdateAdministratorUserResult;
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
namespace Phantom.Controller.Services.Users;
|
namespace Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
public enum DeleteUserResult : byte {
|
public enum DeleteUserResult : byte {
|
||||||
Deleted,
|
Deleted,
|
11
Common/Phantom.Common.Data.Web/Users/LogInSuccess.cs
Normal file
11
Common/Phantom.Common.Data.Web/Users/LogInSuccess.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using MemoryPack;
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record LogInSuccess (
|
||||||
|
[property: MemoryPackOrder(0)] Guid UserGuid,
|
||||||
|
[property: MemoryPackOrder(1)] PermissionSet Permissions,
|
||||||
|
[property: MemoryPackOrder(2)] ImmutableArray<byte> Token
|
||||||
|
);
|
@ -0,0 +1,27 @@
|
|||||||
|
using MemoryPack;
|
||||||
|
using Phantom.Common.Data.Web.Users.PasswordRequirementViolations;
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users {
|
||||||
|
[MemoryPackable]
|
||||||
|
[MemoryPackUnion(0, typeof(TooShort))]
|
||||||
|
[MemoryPackUnion(1, typeof(MustContainLowercaseLetter))]
|
||||||
|
[MemoryPackUnion(2, typeof(MustContainUppercaseLetter))]
|
||||||
|
[MemoryPackUnion(3, typeof(MustContainDigit))]
|
||||||
|
public abstract partial record PasswordRequirementViolation {
|
||||||
|
internal PasswordRequirementViolation() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users.PasswordRequirementViolations {
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record TooShort([property: MemoryPackOrder(0)] int MinimumLength) : PasswordRequirementViolation;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record MustContainLowercaseLetter : PasswordRequirementViolation;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record MustContainUppercaseLetter : PasswordRequirementViolation;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record MustContainDigit : PasswordRequirementViolation;
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
namespace Phantom.Controller.Services.Users.Permissions;
|
namespace Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
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 ();
|
29
Common/Phantom.Common.Data.Web/Users/PermissionSet.cs
Normal file
29
Common/Phantom.Common.Data.Web/Users/PermissionSet.cs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using MemoryPack;
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial class PermissionSet {
|
||||||
|
public static PermissionSet None { get; } = new (ImmutableHashSet<string>.Empty);
|
||||||
|
|
||||||
|
[MemoryPackOrder(0)]
|
||||||
|
[MemoryPackInclude]
|
||||||
|
private readonly ImmutableHashSet<string> permissionIds;
|
||||||
|
|
||||||
|
public PermissionSet(ImmutableHashSet<string> permissionIds) {
|
||||||
|
this.permissionIds = permissionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Check(Permission? permission) {
|
||||||
|
while (permission != null) {
|
||||||
|
if (!permissionIds.Contains(permission.Id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
permission = permission.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
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
|
||||||
|
);
|
24
Common/Phantom.Common.Data.Web/Users/SetUserPasswordError.cs
Normal file
24
Common/Phantom.Common.Data.Web/Users/SetUserPasswordError.cs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using MemoryPack;
|
||||||
|
using Phantom.Common.Data.Web.Users.SetUserPasswordErrors;
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users {
|
||||||
|
[MemoryPackable]
|
||||||
|
[MemoryPackUnion(0, typeof(UserNotFound))]
|
||||||
|
[MemoryPackUnion(1, typeof(PasswordIsInvalid))]
|
||||||
|
[MemoryPackUnion(2, typeof(UnknownError))]
|
||||||
|
public abstract partial record SetUserPasswordError {
|
||||||
|
internal SetUserPasswordError() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users.SetUserPasswordErrors {
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record UserNotFound : SetUserPasswordError;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record PasswordIsInvalid([property: MemoryPackOrder(0)] ImmutableArray<PasswordRequirementViolation> Violations) : SetUserPasswordError;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record UnknownError : 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
|
||||||
|
);
|
12
Common/Phantom.Common.Data.Web/Users/UserPasswords.cs
Normal file
12
Common/Phantom.Common.Data.Web/Users/UserPasswords.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
namespace Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
|
public static class UserPasswords {
|
||||||
|
public static string Hash(string password) {
|
||||||
|
return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Verify(string password, string hash) {
|
||||||
|
// TODO rehash
|
||||||
|
return BCrypt.Net.BCrypt.Verify(password, hash);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
using MemoryPack;
|
||||||
|
using Phantom.Common.Data.Web.Users.UsernameRequirementViolations;
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users {
|
||||||
|
[MemoryPackable]
|
||||||
|
[MemoryPackUnion(0, typeof(IsEmpty))]
|
||||||
|
[MemoryPackUnion(1, typeof(TooLong))]
|
||||||
|
public abstract partial record UsernameRequirementViolation {
|
||||||
|
internal UsernameRequirementViolation() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Phantom.Common.Data.Web.Users.UsernameRequirementViolations {
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record IsEmpty : UsernameRequirementViolation;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record TooLong([property: MemoryPackOrder(0)] int MaxLength) : UsernameRequirementViolation;
|
||||||
|
}
|
@ -2,7 +2,7 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using MemoryPack;
|
using MemoryPack;
|
||||||
|
|
||||||
namespace Phantom.Common.Data.Agent;
|
namespace Phantom.Common.Data;
|
||||||
|
|
||||||
[MemoryPackable(GenerateType.VersionTolerant)]
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
@ -1,4 +1,4 @@
|
|||||||
namespace Phantom.Common.Data.Agent;
|
namespace Phantom.Common.Data;
|
||||||
|
|
||||||
public readonly record struct ConnectionCommonKey(byte[] CertificatePublicKey, AuthToken AuthToken) {
|
public readonly record struct ConnectionCommonKey(byte[] CertificatePublicKey, AuthToken AuthToken) {
|
||||||
private const byte TokenLength = AuthToken.Length;
|
private const byte TokenLength = AuthToken.Length;
|
@ -1,4 +1,5 @@
|
|||||||
using MemoryPack;
|
using MemoryPack;
|
||||||
|
using Phantom.Common.Data;
|
||||||
using Phantom.Common.Data.Agent;
|
using Phantom.Common.Data.Agent;
|
||||||
using Phantom.Utils.Rpc.Message;
|
using Phantom.Utils.Rpc.Message;
|
||||||
|
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
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<NoReply> HandleRegisterWeb(RegisterWebMessage message);
|
||||||
|
Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message);
|
||||||
|
Task<LogInSuccess?> HandleLogIn(LogIn message);
|
||||||
Task<NoReply> HandleReply(ReplyMessage message);
|
Task<NoReply> HandleReply(ReplyMessage message);
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
using Phantom.Common.Messages.Web.BiDirectional;
|
using Phantom.Common.Messages.Web.BiDirectional;
|
||||||
|
using Phantom.Common.Messages.Web.ToWeb;
|
||||||
using Phantom.Utils.Rpc.Message;
|
using Phantom.Utils.Rpc.Message;
|
||||||
|
|
||||||
namespace Phantom.Common.Messages.Web;
|
namespace Phantom.Common.Messages.Web;
|
||||||
|
|
||||||
public interface IMessageToWebListener {
|
public interface IMessageToWebListener {
|
||||||
|
Task<NoReply> HandleRegisterWebResult(RegisterWebResultMessage message);
|
||||||
Task<NoReply> HandleReply(ReplyMessage message);
|
Task<NoReply> HandleReply(ReplyMessage message);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
using MemoryPack;
|
||||||
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
|
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<CreateOrUpdateAdministratorUserResult> {
|
||||||
|
public Task<CreateOrUpdateAdministratorUserResult> Accept(IMessageToControllerListener listener) {
|
||||||
|
return listener.CreateOrUpdateAdministratorUser(this);
|
||||||
|
}
|
||||||
|
}
|
14
Common/Phantom.Common.Messages.Web/ToController/LogIn.cs
Normal file
14
Common/Phantom.Common.Messages.Web/ToController/LogIn.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
using MemoryPack;
|
||||||
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
|
namespace Phantom.Common.Messages.Web.ToController;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record LogIn(
|
||||||
|
[property: MemoryPackOrder(0)] string Username,
|
||||||
|
[property: MemoryPackOrder(1)] string Password
|
||||||
|
) : IMessageToController<LogInSuccess?> {
|
||||||
|
public Task<LogInSuccess?> Accept(IMessageToControllerListener listener) {
|
||||||
|
return listener.HandleLogIn(this);
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,14 @@
|
|||||||
|
using MemoryPack;
|
||||||
|
using Phantom.Common.Data;
|
||||||
|
using Phantom.Utils.Rpc.Message;
|
||||||
|
|
||||||
|
namespace Phantom.Common.Messages.Web.ToController;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record RegisterWebMessage(
|
||||||
|
[property: MemoryPackOrder(0)] AuthToken AuthToken
|
||||||
|
) : IMessageToController {
|
||||||
|
public Task<NoReply> Accept(IMessageToControllerListener listener) {
|
||||||
|
return listener.HandleRegisterWeb(this);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
using MemoryPack;
|
||||||
|
using Phantom.Utils.Rpc.Message;
|
||||||
|
|
||||||
|
namespace Phantom.Common.Messages.Web.ToWeb;
|
||||||
|
|
||||||
|
[MemoryPackable(GenerateType.VersionTolerant)]
|
||||||
|
public sealed partial record RegisterWebResultMessage(
|
||||||
|
[property: MemoryPackOrder(0)] bool Success
|
||||||
|
) : IMessageToWeb {
|
||||||
|
public Task<NoReply> Accept(IMessageToWebListener listener) {
|
||||||
|
return listener.HandleRegisterWebResult(this);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,8 @@
|
|||||||
using Phantom.Common.Logging;
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
using Phantom.Common.Messages.Web.BiDirectional;
|
using Phantom.Common.Messages.Web.BiDirectional;
|
||||||
|
using Phantom.Common.Messages.Web.ToController;
|
||||||
|
using Phantom.Common.Messages.Web.ToWeb;
|
||||||
using Phantom.Utils.Rpc.Message;
|
using Phantom.Utils.Rpc.Message;
|
||||||
|
|
||||||
namespace Phantom.Common.Messages.Web;
|
namespace Phantom.Common.Messages.Web;
|
||||||
@ -11,8 +14,12 @@ public static class WebMessageRegistries {
|
|||||||
public static IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> Definitions { get; } = new MessageDefinitions();
|
public static IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> Definitions { get; } = new MessageDefinitions();
|
||||||
|
|
||||||
static WebMessageRegistries() {
|
static WebMessageRegistries() {
|
||||||
|
ToController.Add<RegisterWebMessage>(0);
|
||||||
|
ToController.Add<CreateOrUpdateAdministratorUser, CreateOrUpdateAdministratorUserResult>(1);
|
||||||
|
ToController.Add<LogIn, LogInSuccess?>(2);
|
||||||
ToController.Add<ReplyMessage>(127);
|
ToController.Add<ReplyMessage>(127);
|
||||||
|
|
||||||
|
ToWeb.Add<RegisterWebResultMessage>(0);
|
||||||
ToWeb.Add<ReplyMessage>(127);
|
ToWeb.Add<ReplyMessage>(127);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,7 +28,7 @@ public static class WebMessageRegistries {
|
|||||||
public MessageRegistry<IMessageToControllerListener> ToServer => ToController;
|
public MessageRegistry<IMessageToControllerListener> ToServer => ToController;
|
||||||
|
|
||||||
public bool IsRegistrationMessage(Type messageType) {
|
public bool IsRegistrationMessage(Type messageType) {
|
||||||
return false;
|
return messageType == typeof(RegisterWebMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReplyMessage CreateReplyMessage(uint sequenceId, byte[] serializedReply) {
|
public ReplyMessage CreateReplyMessage(uint sequenceId, byte[] serializedReply) {
|
||||||
|
@ -4,17 +4,21 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
|
|||||||
|
|
||||||
namespace Phantom.Controller.Database.Postgres;
|
namespace Phantom.Controller.Database.Postgres;
|
||||||
|
|
||||||
public sealed class ApplicationDbContextFactory : IDatabaseProvider {
|
public sealed class ApplicationDbContextFactory : IDbContextProvider {
|
||||||
private readonly PooledDbContextFactory<ApplicationDbContext> factory;
|
private readonly PooledDbContextFactory<ApplicationDbContext> factory;
|
||||||
|
|
||||||
public ApplicationDbContextFactory(string connectionString) {
|
public ApplicationDbContextFactory(string connectionString) {
|
||||||
this.factory = new PooledDbContextFactory<ApplicationDbContext>(CreateOptions(connectionString), poolSize: 32);
|
this.factory = new PooledDbContextFactory<ApplicationDbContext>(CreateOptions(connectionString), poolSize: 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ApplicationDbContext Provide() {
|
public ApplicationDbContext Eager() {
|
||||||
return factory.CreateDbContext();
|
return factory.CreateDbContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ILazyDbContext Lazy() {
|
||||||
|
return new LazyDbContext(this);
|
||||||
|
}
|
||||||
|
|
||||||
private static DbContextOptions<ApplicationDbContext> CreateOptions(string connectionString) {
|
private static DbContextOptions<ApplicationDbContext> CreateOptions(string connectionString) {
|
||||||
var builder = new DbContextOptionsBuilder<ApplicationDbContext>();
|
var builder = new DbContextOptionsBuilder<ApplicationDbContext>();
|
||||||
builder.UseNpgsql(connectionString, ConfigureOptions);
|
builder.UseNpgsql(connectionString, ConfigureOptions);
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
namespace Phantom.Controller.Database.Postgres;
|
||||||
|
|
||||||
|
sealed class LazyDbContext : ILazyDbContext {
|
||||||
|
public ApplicationDbContext Ctx => cachedContext ??= contextFactory.Eager();
|
||||||
|
|
||||||
|
private readonly ApplicationDbContextFactory contextFactory;
|
||||||
|
private ApplicationDbContext? cachedContext;
|
||||||
|
|
||||||
|
internal LazyDbContext(ApplicationDbContextFactory contextFactory) {
|
||||||
|
this.contextFactory = contextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() {
|
||||||
|
return cachedContext?.DisposeAsync() ?? ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
@ -3,9 +3,10 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using Phantom.Common.Data;
|
using Phantom.Common.Data;
|
||||||
using Phantom.Common.Data.Minecraft;
|
using Phantom.Common.Data.Minecraft;
|
||||||
|
using Phantom.Common.Data.Web.AuditLog;
|
||||||
|
using Phantom.Common.Data.Web.EventLog;
|
||||||
using Phantom.Controller.Database.Converters;
|
using Phantom.Controller.Database.Converters;
|
||||||
using Phantom.Controller.Database.Entities;
|
using Phantom.Controller.Database.Entities;
|
||||||
using Phantom.Controller.Database.Enums;
|
|
||||||
using Phantom.Controller.Database.Factories;
|
using Phantom.Controller.Database.Factories;
|
||||||
|
|
||||||
namespace Phantom.Controller.Database;
|
namespace Phantom.Controller.Database;
|
||||||
|
@ -8,8 +8,8 @@ namespace Phantom.Controller.Database;
|
|||||||
public static class DatabaseMigrator {
|
public static class DatabaseMigrator {
|
||||||
private static readonly ILogger Logger = PhantomLogger.Create(nameof(DatabaseMigrator));
|
private static readonly ILogger Logger = PhantomLogger.Create(nameof(DatabaseMigrator));
|
||||||
|
|
||||||
public static async Task Run(IDatabaseProvider databaseProvider, CancellationToken cancellationToken) {
|
public static async Task Run(IDbContextProvider dbProvider, CancellationToken cancellationToken) {
|
||||||
await using var ctx = databaseProvider.Provide();
|
await using var ctx = dbProvider.Eager();
|
||||||
|
|
||||||
Logger.Information("Connecting to database...");
|
Logger.Information("Connecting to database...");
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Phantom.Controller.Database.Enums;
|
using Phantom.Common.Data.Web.AuditLog;
|
||||||
|
|
||||||
namespace Phantom.Controller.Database.Entities;
|
namespace Phantom.Controller.Database.Entities;
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Phantom.Controller.Database.Enums;
|
using Phantom.Common.Data.Web.EventLog;
|
||||||
|
|
||||||
namespace Phantom.Controller.Database.Entities;
|
namespace Phantom.Controller.Database.Entities;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
|
||||||
@ -11,9 +12,13 @@ public sealed class UserEntity {
|
|||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public string PasswordHash { get; set; }
|
public string PasswordHash { get; set; }
|
||||||
|
|
||||||
public UserEntity(Guid userGuid, string name) {
|
public UserEntity(Guid userGuid, string name, string passwordHash) {
|
||||||
UserGuid = userGuid;
|
UserGuid = userGuid;
|
||||||
Name = name;
|
Name = name;
|
||||||
PasswordHash = null!;
|
PasswordHash = passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserInfo ToUserInfo() {
|
||||||
|
return new UserInfo(UserGuid, Name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
namespace Phantom.Controller.Database.Enums;
|
|
||||||
|
|
||||||
public enum EventLogSubjectType {
|
|
||||||
Instance
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
namespace Phantom.Controller.Database;
|
|
||||||
|
|
||||||
public interface IDatabaseProvider {
|
|
||||||
ApplicationDbContext Provide();
|
|
||||||
}
|
|
@ -0,0 +1,6 @@
|
|||||||
|
namespace Phantom.Controller.Database;
|
||||||
|
|
||||||
|
public interface IDbContextProvider {
|
||||||
|
ApplicationDbContext Eager();
|
||||||
|
ILazyDbContext Lazy();
|
||||||
|
}
|
5
Controller/Phantom.Controller.Database/ILazyDbContext.cs
Normal file
5
Controller/Phantom.Controller.Database/ILazyDbContext.cs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
namespace Phantom.Controller.Database;
|
||||||
|
|
||||||
|
public interface ILazyDbContext : IAsyncDisposable {
|
||||||
|
ApplicationDbContext Ctx { get; }
|
||||||
|
}
|
@ -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>
|
||||||
|
|
||||||
|
@ -0,0 +1,66 @@
|
|||||||
|
using Phantom.Common.Data.Web.AuditLog;
|
||||||
|
using Phantom.Controller.Database.Entities;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Database.Repositories;
|
||||||
|
|
||||||
|
sealed partial class AuditLogRepository {
|
||||||
|
public void AddUserLoggedInEvent(UserEntity user) {
|
||||||
|
AddItem(AuditLogEventType.UserLoggedIn, user.UserGuid.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddUserLoggedOutEvent(Guid userGuid) {
|
||||||
|
AddItem(AuditLogEventType.UserLoggedOut, userGuid.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddUserCreatedEvent(UserEntity user) {
|
||||||
|
AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddUserPasswordChangedEvent(UserEntity user) {
|
||||||
|
AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddUserRolesChangedEvent(UserEntity user, List<string> addedToRoles, List<string> removedFromRoles) {
|
||||||
|
var extra = new Dictionary<string, object?>();
|
||||||
|
|
||||||
|
if (addedToRoles.Count > 0) {
|
||||||
|
extra["addedToRoles"] = addedToRoles;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedFromRoles.Count > 0) {
|
||||||
|
extra["removedFromRoles"] = removedFromRoles;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddItem(AuditLogEventType.UserRolesChanged, user.UserGuid.ToString(), extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddUserDeletedEvent(UserEntity user) {
|
||||||
|
AddItem(AuditLogEventType.UserDeleted, user.UserGuid.ToString(), new Dictionary<string, object?> {
|
||||||
|
{ "username", user.Name }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddInstanceCreatedEvent(Guid instanceGuid) {
|
||||||
|
AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddInstanceEditedEvent(Guid instanceGuid) {
|
||||||
|
AddItem(AuditLogEventType.InstanceEdited, instanceGuid.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddInstanceLaunchedEvent(Guid instanceGuid) {
|
||||||
|
AddItem(AuditLogEventType.InstanceLaunched, instanceGuid.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddInstanceCommandExecutedEvent(Guid instanceGuid, string command) {
|
||||||
|
AddItem(AuditLogEventType.InstanceCommandExecuted, instanceGuid.ToString(), new Dictionary<string, object?> {
|
||||||
|
{ "command", command }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddInstanceStoppedEvent(Guid instanceGuid, int stopInSeconds) {
|
||||||
|
AddItem(AuditLogEventType.InstanceStopped, instanceGuid.ToString(), new Dictionary<string, object?> {
|
||||||
|
{ "stop_in_seconds", stopInSeconds.ToString() }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Common.Data.Web.AuditLog;
|
||||||
|
using Phantom.Controller.Database.Entities;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Database.Repositories;
|
||||||
|
|
||||||
|
public sealed partial class AuditLogRepository {
|
||||||
|
private readonly ILazyDbContext db;
|
||||||
|
private readonly Guid? currentUserGuid;
|
||||||
|
|
||||||
|
public AuditLogRepository(ILazyDbContext db, Guid? currentUserGuid) {
|
||||||
|
this.db = db;
|
||||||
|
this.currentUserGuid = currentUserGuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddItem(AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
|
||||||
|
db.Ctx.AuditLog.Add(new AuditLogEntity(currentUserGuid, eventType, subjectId, extra));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AuditLogItem[]> GetItems(int count, CancellationToken cancellationToken) {
|
||||||
|
return db.Ctx
|
||||||
|
.AuditLog
|
||||||
|
.Include(static entity => entity.User)
|
||||||
|
.AsQueryable()
|
||||||
|
.OrderByDescending(static entity => entity.UtcTime)
|
||||||
|
.Take(count)
|
||||||
|
.Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
using Phantom.Controller.Database.Entities;
|
||||||
|
using Phantom.Utils.Collections;
|
||||||
|
using Phantom.Utils.Tasks;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Database.Repositories;
|
||||||
|
|
||||||
|
public sealed class RoleRepository {
|
||||||
|
private const int MaxRoleNameLength = 40;
|
||||||
|
|
||||||
|
private readonly ILazyDbContext db;
|
||||||
|
|
||||||
|
public RoleRepository(ILazyDbContext db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<RoleEntity>> GetAll() {
|
||||||
|
return db.Ctx.Roles.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ImmutableHashSet<string>> GetAllNames() {
|
||||||
|
return db.Ctx.Roles.Select(static role => role.Name).AsAsyncEnumerable().ToImmutableSetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<RoleEntity?> GetByGuid(Guid guid) {
|
||||||
|
return db.Ctx.Roles.FindAsync(guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RoleEntity, AddRoleError>> Create(string name) {
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) {
|
||||||
|
return AddRoleError.NameIsEmpty;
|
||||||
|
}
|
||||||
|
else if (name.Length > MaxRoleNameLength) {
|
||||||
|
return AddRoleError.NameIsTooLong;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await db.Ctx.Roles.AnyAsync(role => role.Name == name)) {
|
||||||
|
return AddRoleError.NameAlreadyExists;
|
||||||
|
}
|
||||||
|
|
||||||
|
var role = new RoleEntity(Guid.NewGuid(), name);
|
||||||
|
db.Ctx.Roles.Add(role);
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,115 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
using Phantom.Common.Data.Web.Users.AddUserErrors;
|
||||||
|
using Phantom.Common.Data.Web.Users.PasswordRequirementViolations;
|
||||||
|
using Phantom.Common.Data.Web.Users.UsernameRequirementViolations;
|
||||||
|
using Phantom.Controller.Database.Entities;
|
||||||
|
using Phantom.Utils.Collections;
|
||||||
|
using Phantom.Utils.Tasks;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Database.Repositories;
|
||||||
|
|
||||||
|
public sealed class UserRepository {
|
||||||
|
private const int MaxUserNameLength = 40;
|
||||||
|
private const int MinimumPasswordLength = 16;
|
||||||
|
|
||||||
|
private static UsernameRequirementViolation? CheckUsernameRequirements(string username) {
|
||||||
|
if (string.IsNullOrWhiteSpace(username)) {
|
||||||
|
return new IsEmpty();
|
||||||
|
}
|
||||||
|
else if (username.Length > MaxUserNameLength) {
|
||||||
|
return new TooLong(MaxUserNameLength);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<PasswordRequirementViolation> CheckPasswordRequirements(string password) {
|
||||||
|
var violations = ImmutableArray.CreateBuilder<PasswordRequirementViolation>();
|
||||||
|
|
||||||
|
if (password.Length < MinimumPasswordLength) {
|
||||||
|
violations.Add(new TooShort(MinimumPasswordLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password.Any(char.IsLower)) {
|
||||||
|
violations.Add(new MustContainLowercaseLetter());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password.Any(char.IsUpper)) {
|
||||||
|
violations.Add(new MustContainUppercaseLetter());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password.Any(char.IsDigit)) {
|
||||||
|
violations.Add(new MustContainDigit());
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations.ToImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ILazyDbContext db;
|
||||||
|
|
||||||
|
private AuditLogRepository? auditLog;
|
||||||
|
private AuditLogRepository AuditLogRepository => this.auditLog ??= new AuditLogRepository(db, null);
|
||||||
|
|
||||||
|
public UserRepository(ILazyDbContext db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ImmutableArray<UserEntity>> GetAll() {
|
||||||
|
return db.Ctx.Users.AsAsyncEnumerable().ToImmutableArrayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) {
|
||||||
|
return db.Ctx.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserEntity?> GetByGuid(Guid guid) {
|
||||||
|
return await db.Ctx.Users.FindAsync(guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<UserEntity?> GetByName(string username) {
|
||||||
|
return db.Ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) {
|
||||||
|
var usernameRequirementViolation = CheckUsernameRequirements(username);
|
||||||
|
if (usernameRequirementViolation != null) {
|
||||||
|
return new NameIsInvalid(usernameRequirementViolation);
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordRequirementViolations = CheckPasswordRequirements(password);
|
||||||
|
if (!passwordRequirementViolations.IsEmpty) {
|
||||||
|
return new PasswordIsInvalid(passwordRequirementViolations);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await db.Ctx.Users.AnyAsync(user => user.Name == username)) {
|
||||||
|
return new NameAlreadyExists();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new UserEntity(Guid.NewGuid(), username, UserPasswords.Hash(password));
|
||||||
|
|
||||||
|
db.Ctx.Users.Add(user);
|
||||||
|
AuditLogRepository.AddUserCreatedEvent(user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<SetUserPasswordError> SetUserPassword(UserEntity user, string password) {
|
||||||
|
var requirementViolations = CheckPasswordRequirements(password);
|
||||||
|
if (!requirementViolations.IsEmpty) {
|
||||||
|
return new Common.Data.Web.Users.SetUserPasswordErrors.PasswordIsInvalid(requirementViolations);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PasswordHash = UserPasswords.Hash(password);
|
||||||
|
AuditLogRepository.AddUserPasswordChangedEvent(user);
|
||||||
|
|
||||||
|
return Result.Ok<SetUserPasswordError>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteUser(UserEntity user) {
|
||||||
|
db.Ctx.Users.Remove(user);
|
||||||
|
AuditLogRepository.AddUserDeletedEvent(user);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Controller.Database.Entities;
|
||||||
|
using Phantom.Utils.Collections;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Database.Repositories;
|
||||||
|
|
||||||
|
public sealed class UserRoleRepository {
|
||||||
|
private readonly ILazyDbContext db;
|
||||||
|
|
||||||
|
public UserRoleRepository(ILazyDbContext db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() {
|
||||||
|
return db.Ctx.UserRoles
|
||||||
|
.Include(static ur => ur.Role)
|
||||||
|
.GroupBy(static ur => ur.UserGuid, static ur => ur.Role)
|
||||||
|
.ToDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ImmutableArray<RoleEntity>> GetUserRoles(UserEntity user) {
|
||||||
|
return db.Ctx.UserRoles
|
||||||
|
.Include(static ur => ur.Role)
|
||||||
|
.Where(ur => ur.UserGuid == user.UserGuid)
|
||||||
|
.Select(static ur => ur.Role)
|
||||||
|
.AsAsyncEnumerable()
|
||||||
|
.ToImmutableArrayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ImmutableHashSet<Guid>> GetUserRoleGuids(UserEntity user) {
|
||||||
|
return db.Ctx.UserRoles
|
||||||
|
.Where(ur => ur.UserGuid == user.UserGuid)
|
||||||
|
.Select(static ur => ur.RoleGuid)
|
||||||
|
.AsAsyncEnumerable()
|
||||||
|
.ToImmutableSetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Add(UserEntity user, RoleEntity role) {
|
||||||
|
var userRole = await db.Ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
|
||||||
|
if (userRole == null) {
|
||||||
|
db.Ctx.UserRoles.Add(new UserRoleEntity(user.UserGuid, role.RoleGuid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserRoleEntity?> Remove(UserEntity user, RoleEntity role) {
|
||||||
|
var userRole = await db.Ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
|
||||||
|
if (userRole == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
db.Ctx.UserRoles.Remove(userRole);
|
||||||
|
return userRole;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -66,7 +66,7 @@ public sealed class RpcConnectionToClient<TListener> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await socket.SendAsync(routingId, bytes);
|
await socket.SendAsync(routingId, bytes);
|
||||||
return await messageReplyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
|
return await messageReplyTracker.TryWaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(IReply message) {
|
public void Receive(IReply message) {
|
||||||
|
@ -11,7 +11,7 @@ using Phantom.Controller.Services.Instances;
|
|||||||
using Phantom.Utils.Collections;
|
using Phantom.Utils.Collections;
|
||||||
using Phantom.Utils.Events;
|
using Phantom.Utils.Events;
|
||||||
using Phantom.Utils.Tasks;
|
using Phantom.Utils.Tasks;
|
||||||
using ILogger = Serilog.ILogger;
|
using Serilog;
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Agents;
|
namespace Phantom.Controller.Services.Agents;
|
||||||
|
|
||||||
@ -27,17 +27,17 @@ public sealed class AgentManager {
|
|||||||
|
|
||||||
private readonly CancellationToken cancellationToken;
|
private readonly CancellationToken cancellationToken;
|
||||||
private readonly AuthToken authToken;
|
private readonly AuthToken authToken;
|
||||||
private readonly IDatabaseProvider databaseProvider;
|
private readonly IDbContextProvider dbProvider;
|
||||||
|
|
||||||
public AgentManager(AuthToken authToken, IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
public AgentManager(AuthToken authToken, IDbContextProvider dbProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
||||||
this.authToken = authToken;
|
this.authToken = authToken;
|
||||||
this.databaseProvider = databaseProvider;
|
this.dbProvider = dbProvider;
|
||||||
this.cancellationToken = cancellationToken;
|
this.cancellationToken = cancellationToken;
|
||||||
taskManager.Run("Refresh agent status loop", RefreshAgentStatus);
|
taskManager.Run("Refresh agent status loop", RefreshAgentStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task Initialize() {
|
internal async Task Initialize() {
|
||||||
await using var ctx = databaseProvider.Provide();
|
await using var ctx = dbProvider.Eager();
|
||||||
|
|
||||||
await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
|
await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
|
||||||
var agent = new Agent(entity.AgentGuid, entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
|
var agent = new Agent(entity.AgentGuid, entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
|
||||||
@ -68,7 +68,7 @@ public sealed class AgentManager {
|
|||||||
oldAgent.Connection?.Close();
|
oldAgent.Connection?.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
await using (var ctx = databaseProvider.Provide()) {
|
await using (var ctx = dbProvider.Eager()) {
|
||||||
var entity = ctx.AgentUpsert.Fetch(agent.Guid);
|
var entity = ctx.AgentUpsert.Fetch(agent.Guid);
|
||||||
|
|
||||||
entity.Name = agent.Name;
|
entity.Name = agent.Name;
|
||||||
|
@ -1,70 +0,0 @@
|
|||||||
using Phantom.Controller.Database.Entities;
|
|
||||||
using Phantom.Controller.Database.Enums;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Audit;
|
|
||||||
|
|
||||||
public sealed partial class AuditLog {
|
|
||||||
public Task AddAdministratorUserCreatedEvent(UserEntity administratorUser) {
|
|
||||||
return AddItem(AuditLogEventType.AdministratorUserCreated, administratorUser.UserGuid.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddAdministratorUserModifiedEvent(UserEntity administratorUser) {
|
|
||||||
return AddItem(AuditLogEventType.AdministratorUserModified, administratorUser.UserGuid.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddUserLoggedInEvent(UserEntity user) {
|
|
||||||
AddItem(user.UserGuid, AuditLogEventType.UserLoggedIn, user.UserGuid.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddUserLoggedOutEvent(Guid userGuid) {
|
|
||||||
AddItem(userGuid, AuditLogEventType.UserLoggedOut, userGuid.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddUserCreatedEvent(UserEntity user) {
|
|
||||||
return AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddUserRolesChangedEvent(UserEntity user, List<string> addedToRoles, List<string> removedFromRoles) {
|
|
||||||
var extra = new Dictionary<string, object?>();
|
|
||||||
|
|
||||||
if (addedToRoles.Count > 0) {
|
|
||||||
extra["addedToRoles"] = addedToRoles;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removedFromRoles.Count > 0) {
|
|
||||||
extra["removedFromRoles"] = removedFromRoles;
|
|
||||||
}
|
|
||||||
|
|
||||||
return AddItem(AuditLogEventType.UserRolesChanged, user.UserGuid.ToString(), extra);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddUserDeletedEvent(UserEntity user) {
|
|
||||||
return AddItem(AuditLogEventType.UserDeleted, user.UserGuid.ToString(), new Dictionary<string, object?> {
|
|
||||||
{ "username", user.Name }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddInstanceCreatedEvent(Guid instanceGuid) {
|
|
||||||
return AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddInstanceEditedEvent(Guid instanceGuid) {
|
|
||||||
return AddItem(AuditLogEventType.InstanceEdited, instanceGuid.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddInstanceLaunchedEvent(Guid instanceGuid) {
|
|
||||||
return AddItem(AuditLogEventType.InstanceLaunched, instanceGuid.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddInstanceCommandExecutedEvent(Guid instanceGuid, string command) {
|
|
||||||
return AddItem(AuditLogEventType.InstanceCommandExecuted, instanceGuid.ToString(), new Dictionary<string, object?> {
|
|
||||||
{ "command", command }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task AddInstanceStoppedEvent(Guid instanceGuid, int stopInSeconds) {
|
|
||||||
return AddItem(AuditLogEventType.InstanceStopped, instanceGuid.ToString(), new Dictionary<string, object?> {
|
|
||||||
{ "stop_in_seconds", stopInSeconds.ToString() }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Phantom.Controller.Database;
|
|
||||||
using Phantom.Controller.Database.Entities;
|
|
||||||
using Phantom.Controller.Database.Enums;
|
|
||||||
using Phantom.Utils.Tasks;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Audit;
|
|
||||||
|
|
||||||
public sealed partial class AuditLog {
|
|
||||||
private readonly IDatabaseProvider databaseProvider;
|
|
||||||
private readonly TaskManager taskManager;
|
|
||||||
private readonly CancellationToken cancellationToken;
|
|
||||||
|
|
||||||
public AuditLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
|
||||||
this.databaseProvider = databaseProvider;
|
|
||||||
this.taskManager = taskManager;
|
|
||||||
this.cancellationToken = cancellationToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task<Guid?> GetCurrentAuthenticatedUserId() {
|
|
||||||
return Task.FromResult<Guid?>(null); // TODO
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddEntityToDatabase(AuditLogEntity logEntity) {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
ctx.AuditLog.Add(logEntity);
|
|
||||||
await ctx.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddItem(Guid? userGuid, AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
|
|
||||||
var logEntity = new AuditLogEntity(userGuid, eventType, subjectId, extra);
|
|
||||||
taskManager.Run("Store audit log item to database", () => AddEntityToDatabase(logEntity));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddItem(AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
|
|
||||||
AddItem(await GetCurrentAuthenticatedUserId(), eventType, subjectId, extra);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<AuditLogItem[]> GetItems(int count, CancellationToken cancellationToken) {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
return await ctx.AuditLog
|
|
||||||
.Include(static entity => entity.User)
|
|
||||||
.AsQueryable()
|
|
||||||
.OrderByDescending(static entity => entity.UtcTime)
|
|
||||||
.Take(count)
|
|
||||||
.Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
|
|
||||||
.ToArrayAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
using Phantom.Common.Data.Agent;
|
using Phantom.Common.Data;
|
||||||
using Phantom.Common.Logging;
|
using Phantom.Common.Logging;
|
||||||
using Phantom.Common.Messages.Agent;
|
using Phantom.Common.Messages.Agent;
|
||||||
using Phantom.Common.Messages.Web;
|
using Phantom.Common.Messages.Web;
|
||||||
@ -10,8 +10,6 @@ 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;
|
||||||
using Phantom.Controller.Services.Users;
|
using Phantom.Controller.Services.Users;
|
||||||
using Phantom.Controller.Services.Users.Permissions;
|
|
||||||
using Phantom.Controller.Services.Users.Roles;
|
|
||||||
using Phantom.Utils.Tasks;
|
using Phantom.Utils.Tasks;
|
||||||
|
|
||||||
namespace Phantom.Controller.Services;
|
namespace Phantom.Controller.Services;
|
||||||
@ -19,7 +17,7 @@ namespace Phantom.Controller.Services;
|
|||||||
public sealed class ControllerServices {
|
public sealed class ControllerServices {
|
||||||
private TaskManager TaskManager { get; }
|
private TaskManager TaskManager { get; }
|
||||||
private MinecraftVersions MinecraftVersions { get; }
|
private MinecraftVersions MinecraftVersions { 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; }
|
||||||
@ -28,28 +26,32 @@ public sealed class ControllerServices {
|
|||||||
|
|
||||||
private UserManager UserManager { get; }
|
private UserManager UserManager { get; }
|
||||||
private RoleManager RoleManager { get; }
|
private RoleManager RoleManager { get; }
|
||||||
private UserRoleManager UserRoleManager { get; }
|
|
||||||
private PermissionManager PermissionManager { get; }
|
private PermissionManager PermissionManager { get; }
|
||||||
|
|
||||||
private readonly IDatabaseProvider databaseProvider;
|
private UserLoginManager UserLoginManager { get; }
|
||||||
|
|
||||||
|
private readonly IDbContextProvider dbProvider;
|
||||||
|
private readonly AuthToken webAuthToken;
|
||||||
private readonly CancellationToken cancellationToken;
|
private readonly CancellationToken cancellationToken;
|
||||||
|
|
||||||
public ControllerServices(IDatabaseProvider databaseProvider, AuthToken agentAuthToken, CancellationToken shutdownCancellationToken) {
|
public ControllerServices(IDbContextProvider dbProvider, AuthToken agentAuthToken, AuthToken webAuthToken, CancellationToken shutdownCancellationToken) {
|
||||||
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.AgentManager = new AgentManager(agentAuthToken, databaseProvider, TaskManager, shutdownCancellationToken);
|
this.AgentManager = new AgentManager(agentAuthToken, dbProvider, TaskManager, shutdownCancellationToken);
|
||||||
this.AgentJavaRuntimesManager = new AgentJavaRuntimesManager();
|
this.AgentJavaRuntimesManager = new AgentJavaRuntimesManager();
|
||||||
this.EventLog = new EventLog(databaseProvider, TaskManager, shutdownCancellationToken);
|
this.EventLog = new EventLog(dbProvider, TaskManager, shutdownCancellationToken);
|
||||||
this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, databaseProvider, shutdownCancellationToken);
|
this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, dbProvider, shutdownCancellationToken);
|
||||||
this.InstanceLogManager = new InstanceLogManager();
|
this.InstanceLogManager = new InstanceLogManager();
|
||||||
|
|
||||||
this.UserManager = new UserManager(databaseProvider);
|
this.UserManager = new UserManager(dbProvider);
|
||||||
this.RoleManager = new RoleManager(databaseProvider);
|
this.RoleManager = new RoleManager(dbProvider);
|
||||||
this.UserRoleManager = new UserRoleManager(databaseProvider);
|
this.PermissionManager = new PermissionManager(dbProvider);
|
||||||
this.PermissionManager = new PermissionManager(databaseProvider);
|
|
||||||
|
|
||||||
this.databaseProvider = databaseProvider;
|
this.UserLoginManager = new UserLoginManager(UserManager, PermissionManager);
|
||||||
|
|
||||||
|
this.dbProvider = dbProvider;
|
||||||
|
this.webAuthToken = webAuthToken;
|
||||||
this.cancellationToken = shutdownCancellationToken;
|
this.cancellationToken = shutdownCancellationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,11 +60,11 @@ public sealed class ControllerServices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public WebMessageListener CreateWebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection) {
|
public WebMessageListener CreateWebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection) {
|
||||||
return new WebMessageListener(connection);
|
return new WebMessageListener(connection, webAuthToken, UserManager, UserLoginManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Initialize() {
|
public async Task Initialize() {
|
||||||
await DatabaseMigrator.Run(databaseProvider, cancellationToken);
|
await DatabaseMigrator.Run(dbProvider, cancellationToken);
|
||||||
await PermissionManager.Initialize();
|
await PermissionManager.Initialize();
|
||||||
await RoleManager.Initialize();
|
await RoleManager.Initialize();
|
||||||
await AgentManager.Initialize();
|
await AgentManager.Initialize();
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
using Phantom.Common.Data.Backups;
|
using Phantom.Common.Data.Backups;
|
||||||
using Phantom.Common.Data.Instance;
|
using Phantom.Common.Data.Instance;
|
||||||
using Phantom.Controller.Database.Enums;
|
using Phantom.Common.Data.Web.EventLog;
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Events;
|
namespace Phantom.Controller.Services.Events;
|
||||||
|
|
||||||
|
@ -1,26 +1,26 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Common.Data.Web.EventLog;
|
||||||
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.Utils.Collections;
|
using Phantom.Utils.Collections;
|
||||||
using Phantom.Utils.Tasks;
|
using Phantom.Utils.Tasks;
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Events;
|
namespace Phantom.Controller.Services.Events;
|
||||||
|
|
||||||
public sealed partial class EventLog {
|
public sealed partial class EventLog {
|
||||||
private readonly IDatabaseProvider databaseProvider;
|
private readonly IDbContextProvider dbProvider;
|
||||||
private readonly TaskManager taskManager;
|
private readonly TaskManager taskManager;
|
||||||
private readonly CancellationToken cancellationToken;
|
private readonly CancellationToken cancellationToken;
|
||||||
|
|
||||||
public EventLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
public EventLog(IDbContextProvider dbProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
||||||
this.databaseProvider = databaseProvider;
|
this.dbProvider = dbProvider;
|
||||||
this.taskManager = taskManager;
|
this.taskManager = taskManager;
|
||||||
this.cancellationToken = cancellationToken;
|
this.cancellationToken = cancellationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddEntityToDatabase(EventLogEntity logEntity) {
|
private async Task AddEntityToDatabase(EventLogEntity logEntity) {
|
||||||
await using var ctx = databaseProvider.Provide();
|
await using var ctx = dbProvider.Eager();
|
||||||
ctx.EventLog.Add(logEntity);
|
ctx.EventLog.Add(logEntity);
|
||||||
await ctx.SaveChangesAsync(cancellationToken);
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
@ -31,7 +31,7 @@ public sealed partial class EventLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ImmutableArray<EventLogItem>> GetItems(int count, CancellationToken cancellationToken) {
|
public async Task<ImmutableArray<EventLogItem>> GetItems(int count, CancellationToken cancellationToken) {
|
||||||
await using var ctx = databaseProvider.Provide();
|
await using var ctx = dbProvider.Eager();
|
||||||
return await ctx.EventLog
|
return await ctx.EventLog
|
||||||
.AsQueryable()
|
.AsQueryable()
|
||||||
.OrderByDescending(static entity => entity.UtcTime)
|
.OrderByDescending(static entity => entity.UtcTime)
|
||||||
|
@ -3,7 +3,7 @@ using System.Collections.Immutable;
|
|||||||
using Phantom.Common.Logging;
|
using Phantom.Common.Logging;
|
||||||
using Phantom.Utils.Collections;
|
using Phantom.Utils.Collections;
|
||||||
using Phantom.Utils.Events;
|
using Phantom.Utils.Events;
|
||||||
using ILogger = Serilog.ILogger;
|
using Serilog;
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Instances;
|
namespace Phantom.Controller.Services.Instances;
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ using Phantom.Common.Data;
|
|||||||
using Phantom.Common.Data.Instance;
|
using Phantom.Common.Data.Instance;
|
||||||
using Phantom.Common.Data.Minecraft;
|
using Phantom.Common.Data.Minecraft;
|
||||||
using Phantom.Common.Data.Replies;
|
using Phantom.Common.Data.Replies;
|
||||||
|
using Phantom.Common.Data.Web.Minecraft;
|
||||||
using Phantom.Common.Logging;
|
using Phantom.Common.Logging;
|
||||||
using Phantom.Common.Messages.Agent;
|
using Phantom.Common.Messages.Agent;
|
||||||
using Phantom.Common.Messages.Agent.ToAgent;
|
using Phantom.Common.Messages.Agent.ToAgent;
|
||||||
@ -13,11 +14,11 @@ using Phantom.Controller.Minecraft;
|
|||||||
using Phantom.Controller.Services.Agents;
|
using Phantom.Controller.Services.Agents;
|
||||||
using Phantom.Utils.Collections;
|
using Phantom.Utils.Collections;
|
||||||
using Phantom.Utils.Events;
|
using Phantom.Utils.Events;
|
||||||
using ILogger = Serilog.ILogger;
|
using Serilog;
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Instances;
|
namespace Phantom.Controller.Services.Instances;
|
||||||
|
|
||||||
public sealed class InstanceManager {
|
sealed class InstanceManager {
|
||||||
private static readonly ILogger Logger = PhantomLogger.Create<InstanceManager>();
|
private static readonly ILogger Logger = PhantomLogger.Create<InstanceManager>();
|
||||||
|
|
||||||
private readonly ObservableInstances instances = new (PhantomLogger.Create<InstanceManager, ObservableInstances>());
|
private readonly ObservableInstances instances = new (PhantomLogger.Create<InstanceManager, ObservableInstances>());
|
||||||
@ -26,20 +27,20 @@ public sealed class InstanceManager {
|
|||||||
|
|
||||||
private readonly AgentManager agentManager;
|
private readonly AgentManager agentManager;
|
||||||
private readonly MinecraftVersions minecraftVersions;
|
private readonly MinecraftVersions minecraftVersions;
|
||||||
private readonly IDatabaseProvider databaseProvider;
|
private readonly IDbContextProvider dbProvider;
|
||||||
private readonly CancellationToken cancellationToken;
|
private readonly CancellationToken cancellationToken;
|
||||||
|
|
||||||
private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1);
|
private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1);
|
||||||
|
|
||||||
public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDatabaseProvider databaseProvider, CancellationToken cancellationToken) {
|
public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
|
||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.minecraftVersions = minecraftVersions;
|
this.minecraftVersions = minecraftVersions;
|
||||||
this.databaseProvider = databaseProvider;
|
this.dbProvider = dbProvider;
|
||||||
this.cancellationToken = cancellationToken;
|
this.cancellationToken = cancellationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Initialize() {
|
public async Task Initialize() {
|
||||||
await using var ctx = databaseProvider.Provide();
|
await using var ctx = dbProvider.Eager();
|
||||||
await foreach (var entity in ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
|
await foreach (var entity in ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
|
||||||
var configuration = new InstanceConfiguration(
|
var configuration = new InstanceConfiguration(
|
||||||
entity.AgentGuid,
|
entity.AgentGuid,
|
||||||
@ -98,7 +99,7 @@ public sealed class InstanceManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.Is(AddOrEditInstanceResult.Success)) {
|
if (result.Is(AddOrEditInstanceResult.Success)) {
|
||||||
await using var ctx = databaseProvider.Provide();
|
await using var ctx = dbProvider.Eager();
|
||||||
InstanceEntity entity = ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
|
InstanceEntity entity = ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
|
||||||
|
|
||||||
entity.AgentGuid = configuration.AgentGuid;
|
entity.AgentGuid = configuration.AgentGuid;
|
||||||
@ -188,8 +189,8 @@ public sealed class InstanceManager {
|
|||||||
try {
|
try {
|
||||||
instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = shouldLaunchAutomatically });
|
instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = shouldLaunchAutomatically });
|
||||||
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
await using var ctx = dbProvider.Eager();
|
||||||
var entity = await ctx.Instances.FindAsync(instanceGuid, cancellationToken);
|
var entity = await ctx.Instances.FindAsync(new object[] { instanceGuid }, cancellationToken);
|
||||||
if (entity != null) {
|
if (entity != null) {
|
||||||
entity.LaunchAutomatically = shouldLaunchAutomatically;
|
entity.LaunchAutomatically = shouldLaunchAutomatically;
|
||||||
await ctx.SaveChangesAsync(cancellationToken);
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
@ -200,7 +201,12 @@ public sealed class InstanceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
|
public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
|
||||||
return await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command));
|
var result = await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command));
|
||||||
|
if (result.Is(SendCommandToInstanceResult.Success)) {
|
||||||
|
// TODO audit log
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task<ImmutableArray<ConfigureInstanceMessage>> GetInstanceConfigurationsForAgent(Guid agentGuid) {
|
internal async Task<ImmutableArray<ConfigureInstanceMessage>> GetInstanceConfigurationsForAgent(Guid agentGuid) {
|
||||||
|
@ -11,6 +11,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="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" />
|
<ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" />
|
||||||
<ProjectReference Include="..\Phantom.Controller.Database\Phantom.Controller.Database.csproj" />
|
<ProjectReference Include="..\Phantom.Controller.Database\Phantom.Controller.Database.csproj" />
|
||||||
<ProjectReference Include="..\Phantom.Controller.Minecraft\Phantom.Controller.Minecraft.csproj" />
|
<ProjectReference Include="..\Phantom.Controller.Minecraft\Phantom.Controller.Minecraft.csproj" />
|
||||||
|
@ -1,15 +1,52 @@
|
|||||||
using Phantom.Common.Messages.Web;
|
using Phantom.Common.Data;
|
||||||
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
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.Common.Messages.Web.ToWeb;
|
||||||
using Phantom.Controller.Rpc;
|
using Phantom.Controller.Rpc;
|
||||||
|
using Phantom.Controller.Services.Users;
|
||||||
using Phantom.Utils.Rpc.Message;
|
using Phantom.Utils.Rpc.Message;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Rpc;
|
namespace Phantom.Controller.Services.Rpc;
|
||||||
|
|
||||||
public sealed class WebMessageListener : IMessageToControllerListener {
|
public sealed class WebMessageListener : IMessageToControllerListener {
|
||||||
private readonly RpcConnectionToClient<IMessageToWebListener> connection;
|
private static readonly ILogger Logger = PhantomLogger.Create<WebMessageListener>();
|
||||||
|
|
||||||
internal WebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection) {
|
private readonly RpcConnectionToClient<IMessageToWebListener> connection;
|
||||||
|
private readonly AuthToken authToken;
|
||||||
|
private readonly UserManager userManager;
|
||||||
|
private readonly UserLoginManager userLoginManager;
|
||||||
|
|
||||||
|
internal WebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection, AuthToken authToken, UserManager userManager, UserLoginManager userLoginManager) {
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
|
this.authToken = authToken;
|
||||||
|
this.userManager = userManager;
|
||||||
|
this.userLoginManager = userLoginManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NoReply> HandleRegisterWeb(RegisterWebMessage message) {
|
||||||
|
if (authToken.FixedTimeEquals(message.AuthToken)) {
|
||||||
|
Logger.Information("Web authorized successfully.");
|
||||||
|
connection.IsAuthorized = true;
|
||||||
|
await connection.Send(new RegisterWebResultMessage(true));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Logger.Warning("Web failed to authorize, invalid token.");
|
||||||
|
await connection.Send(new RegisterWebResultMessage(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoReply.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUser message) {
|
||||||
|
return userManager.CreateOrUpdateAdministrator(message.Username, message.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<LogInSuccess?> HandleLogIn(LogIn message) {
|
||||||
|
return userLoginManager.LogIn(message.Username, message.Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<NoReply> HandleReply(ReplyMessage message) {
|
public Task<NoReply> HandleReply(ReplyMessage message) {
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.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 PasswordIsInvalid(ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError;
|
|
||||||
|
|
||||||
public sealed record UnknownError : AddUserError;
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
namespace Phantom.Controller.Services.Users;
|
|
||||||
|
|
||||||
public abstract record PasswordRequirementViolation {
|
|
||||||
private PasswordRequirementViolation() {}
|
|
||||||
|
|
||||||
public sealed record TooShort(int MinimumLength) : PasswordRequirementViolation;
|
|
||||||
|
|
||||||
public sealed record LowercaseLetterRequired : PasswordRequirementViolation;
|
|
||||||
|
|
||||||
public sealed record UppercaseLetterRequired : PasswordRequirementViolation;
|
|
||||||
|
|
||||||
public sealed record DigitRequired : PasswordRequirementViolation;
|
|
||||||
}
|
|
@ -0,0 +1,56 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
using Phantom.Controller.Database;
|
||||||
|
using Phantom.Controller.Database.Entities;
|
||||||
|
using Phantom.Utils.Collections;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Services.Users;
|
||||||
|
|
||||||
|
sealed class PermissionManager {
|
||||||
|
private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>();
|
||||||
|
|
||||||
|
private readonly IDbContextProvider dbProvider;
|
||||||
|
|
||||||
|
public PermissionManager(IDbContextProvider dbProvider) {
|
||||||
|
this.dbProvider = dbProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Initialize() {
|
||||||
|
Logger.Information("Adding default permissions to database.");
|
||||||
|
|
||||||
|
await using var ctx = dbProvider.Eager();
|
||||||
|
|
||||||
|
var existingPermissionIds = await ctx.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync();
|
||||||
|
var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds);
|
||||||
|
if (!missingPermissionIds.IsEmpty) {
|
||||||
|
Logger.Information("Adding default permissions: {Permissions}", string.Join(", ", missingPermissionIds));
|
||||||
|
|
||||||
|
foreach (var permissionId in missingPermissionIds) {
|
||||||
|
ctx.Permissions.Add(new PermissionEntity(permissionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PermissionSet> FetchPermissionsForUserId(Guid userId) {
|
||||||
|
await using var ctx = dbProvider.Eager();
|
||||||
|
|
||||||
|
var userPermissions = ctx.UserPermissions
|
||||||
|
.Where(up => up.UserGuid == userId)
|
||||||
|
.Select(static up => up.PermissionId);
|
||||||
|
|
||||||
|
var rolePermissions = ctx.UserRoles
|
||||||
|
.Where(ur => ur.UserGuid == userId)
|
||||||
|
.Join(ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
|
||||||
|
|
||||||
|
return new PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
|
||||||
|
return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
|
||||||
|
}
|
||||||
|
}
|
@ -1,29 +0,0 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Users.Permissions;
|
|
||||||
|
|
||||||
public sealed class IdentityPermissions {
|
|
||||||
internal static IdentityPermissions None { get; } = new ();
|
|
||||||
|
|
||||||
private readonly ImmutableHashSet<string> permissionIds;
|
|
||||||
|
|
||||||
internal IdentityPermissions(IQueryable<string> permissionIdsQuery) {
|
|
||||||
this.permissionIds = permissionIdsQuery.ToImmutableHashSet();
|
|
||||||
}
|
|
||||||
|
|
||||||
private IdentityPermissions() {
|
|
||||||
this.permissionIds = ImmutableHashSet<string>.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Check(Permission? permission) {
|
|
||||||
while (permission != null) {
|
|
||||||
if (!permissionIds.Contains(permission.Id)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
permission = permission.Parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,68 +0,0 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Phantom.Common.Logging;
|
|
||||||
using Phantom.Controller.Database;
|
|
||||||
using Phantom.Controller.Database.Entities;
|
|
||||||
using Phantom.Utils.Collections;
|
|
||||||
using ILogger = Serilog.ILogger;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Users.Permissions;
|
|
||||||
|
|
||||||
public sealed class PermissionManager {
|
|
||||||
private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>();
|
|
||||||
|
|
||||||
private readonly IDatabaseProvider databaseProvider;
|
|
||||||
private readonly Dictionary<Guid, IdentityPermissions> userIdsToPermissionIds = new ();
|
|
||||||
|
|
||||||
public PermissionManager(IDatabaseProvider databaseProvider) {
|
|
||||||
this.databaseProvider = databaseProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task Initialize() {
|
|
||||||
Logger.Information("Adding default permissions to database.");
|
|
||||||
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
|
|
||||||
var existingPermissionIds = await ctx.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync();
|
|
||||||
var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds);
|
|
||||||
if (!missingPermissionIds.IsEmpty) {
|
|
||||||
Logger.Information("Adding default permissions: {Permissions}", string.Join(", ", missingPermissionIds));
|
|
||||||
|
|
||||||
foreach (var permissionId in missingPermissionIds) {
|
|
||||||
ctx.Permissions.Add(new PermissionEntity(permissionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
|
|
||||||
return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private IdentityPermissions FetchPermissionsForUserId(Guid userId) {
|
|
||||||
using var ctx = databaseProvider.Provide();
|
|
||||||
var userPermissions = ctx.UserPermissions.Where(up => up.UserGuid == userId).Select(static up => up.PermissionId);
|
|
||||||
var rolePermissions = ctx.UserRoles.Where(ur => ur.UserGuid == userId).Join(ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
|
|
||||||
return new IdentityPermissions(userPermissions.Union(rolePermissions));
|
|
||||||
}
|
|
||||||
|
|
||||||
private IdentityPermissions GetPermissionsForUserId(Guid userId, bool refreshCache) {
|
|
||||||
if (!refreshCache && userIdsToPermissionIds.TryGetValue(userId, out var userPermissions)) {
|
|
||||||
return userPermissions;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return userIdsToPermissionIds[userId] = FetchPermissionsForUserId(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) {
|
|
||||||
Guid? userId = UserManager.GetAuthenticatedUserId(user);
|
|
||||||
return userId == null ? IdentityPermissions.None : GetPermissionsForUserId(userId.Value, refreshCache);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) {
|
|
||||||
return GetPermissions(user, refreshCache).Check(permission);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,7 +1,7 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using Phantom.Controller.Services.Users.Permissions;
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Users.Roles;
|
namespace Phantom.Controller.Services.Users;
|
||||||
|
|
||||||
public sealed record Role(Guid Guid, string Name, ImmutableArray<Permission> Permissions) {
|
public sealed record Role(Guid Guid, string Name, ImmutableArray<Permission> Permissions) {
|
||||||
private static readonly List<Role> AllRoles = new ();
|
private static readonly List<Role> AllRoles = new ();
|
52
Controller/Phantom.Controller.Services/Users/RoleManager.cs
Normal file
52
Controller/Phantom.Controller.Services/Users/RoleManager.cs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
using Phantom.Controller.Database;
|
||||||
|
using Phantom.Controller.Database.Entities;
|
||||||
|
using Phantom.Utils.Collections;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Services.Users;
|
||||||
|
|
||||||
|
sealed class RoleManager {
|
||||||
|
private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>();
|
||||||
|
|
||||||
|
private readonly IDbContextProvider dbProvider;
|
||||||
|
|
||||||
|
public RoleManager(IDbContextProvider dbProvider) {
|
||||||
|
this.dbProvider = dbProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task Initialize() {
|
||||||
|
Logger.Information("Adding default roles to database.");
|
||||||
|
|
||||||
|
await using var ctx = dbProvider.Eager();
|
||||||
|
|
||||||
|
var existingRoleNames = await ctx.Roles
|
||||||
|
.Select(static role => role.Name)
|
||||||
|
.AsAsyncEnumerable()
|
||||||
|
.ToImmutableSetAsync();
|
||||||
|
|
||||||
|
var existingPermissionIdsByRoleGuid = await ctx.RolePermissions
|
||||||
|
.GroupBy(static rp => rp.RoleGuid, static rp => rp.PermissionId)
|
||||||
|
.ToDictionaryAsync(static g => g.Key, static g => g.ToImmutableHashSet());
|
||||||
|
|
||||||
|
foreach (var role in Role.All) {
|
||||||
|
if (!existingRoleNames.Contains(role.Name)) {
|
||||||
|
Logger.Information("Adding default role \"{Name}\".", role.Name);
|
||||||
|
ctx.Roles.Add(new RoleEntity(role.Guid, role.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingPermissionIds = existingPermissionIdsByRoleGuid.TryGetValue(role.Guid, out var ids) ? ids : ImmutableHashSet<string>.Empty;
|
||||||
|
var missingPermissionIds = PermissionManager.GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds);
|
||||||
|
if (!missingPermissionIds.IsEmpty) {
|
||||||
|
Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds));
|
||||||
|
foreach (var permissionId in missingPermissionIds) {
|
||||||
|
ctx.RolePermissions.Add(new RolePermissionEntity(role.Guid, permissionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
@ -1,99 +0,0 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Phantom.Common.Logging;
|
|
||||||
using Phantom.Controller.Database;
|
|
||||||
using Phantom.Controller.Database.Entities;
|
|
||||||
using Phantom.Controller.Services.Users.Permissions;
|
|
||||||
using Phantom.Utils.Collections;
|
|
||||||
using Phantom.Utils.Tasks;
|
|
||||||
using ILogger = Serilog.ILogger;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Users.Roles;
|
|
||||||
|
|
||||||
public sealed class RoleManager {
|
|
||||||
private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>();
|
|
||||||
|
|
||||||
private const int MaxRoleNameLength = 40;
|
|
||||||
|
|
||||||
private readonly IDatabaseProvider databaseProvider;
|
|
||||||
|
|
||||||
public RoleManager(IDatabaseProvider databaseProvider) {
|
|
||||||
this.databaseProvider = databaseProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task Initialize() {
|
|
||||||
Logger.Information("Adding default roles to database.");
|
|
||||||
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
|
|
||||||
var existingRoleNames = await ctx.Roles
|
|
||||||
.Select(static role => role.Name)
|
|
||||||
.AsAsyncEnumerable()
|
|
||||||
.ToImmutableSetAsync();
|
|
||||||
|
|
||||||
var existingPermissionIdsByRoleGuid = await ctx.RolePermissions
|
|
||||||
.GroupBy(static rp => rp.RoleGuid, static rp => rp.PermissionId)
|
|
||||||
.ToDictionaryAsync(static g => g.Key, static g => g.ToImmutableHashSet());
|
|
||||||
|
|
||||||
foreach (var role in Role.All) {
|
|
||||||
if (!existingRoleNames.Contains(role.Name)) {
|
|
||||||
Logger.Information("Adding default role \"{Name}\".", role.Name);
|
|
||||||
ctx.Roles.Add(new RoleEntity(role.Guid, role.Name));
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingPermissionIds = existingPermissionIdsByRoleGuid.TryGetValue(role.Guid, out var ids) ? ids : ImmutableHashSet<string>.Empty;
|
|
||||||
var missingPermissionIds = PermissionManager.GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds);
|
|
||||||
if (!missingPermissionIds.IsEmpty) {
|
|
||||||
Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds));
|
|
||||||
foreach (var permissionId in missingPermissionIds) {
|
|
||||||
ctx.RolePermissions.Add(new RolePermissionEntity(role.Guid, permissionId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<RoleEntity>> GetAll() {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
return await ctx.Roles.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ImmutableHashSet<string>> GetAllNames() {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
return await ctx.Roles.Select(static role => role.Name).AsAsyncEnumerable().ToImmutableSetAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<RoleEntity?> GetByGuid(Guid guid) {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
return await ctx.Roles.FindAsync(guid);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<RoleEntity, AddRoleError>> Create(string name) {
|
|
||||||
if (string.IsNullOrWhiteSpace(name)) {
|
|
||||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsEmpty);
|
|
||||||
}
|
|
||||||
else if (name.Length > MaxRoleNameLength) {
|
|
||||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsTooLong);
|
|
||||||
}
|
|
||||||
|
|
||||||
RoleEntity newRole;
|
|
||||||
try {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
|
|
||||||
if (await ctx.Roles.AnyAsync(role => role.Name == name)) {
|
|
||||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameAlreadyExists);
|
|
||||||
}
|
|
||||||
|
|
||||||
newRole = new RoleEntity(Guid.NewGuid(), name);
|
|
||||||
ctx.Roles.Add(newRole);
|
|
||||||
await ctx.SaveChangesAsync();
|
|
||||||
} catch (Exception e) {
|
|
||||||
Logger.Error(e, "Could not create role \"{Name}\".", name);
|
|
||||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.UnknownError);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Information("Created role \"{Name}\" (GUID {Guid}).", name, newRole.RoleGuid);
|
|
||||||
return Result.Ok<RoleEntity, AddRoleError>(newRole);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,83 +0,0 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Phantom.Common.Logging;
|
|
||||||
using Phantom.Controller.Database;
|
|
||||||
using Phantom.Controller.Database.Entities;
|
|
||||||
using Phantom.Utils.Collections;
|
|
||||||
using ILogger = Serilog.ILogger;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Users.Roles;
|
|
||||||
|
|
||||||
public sealed class UserRoleManager {
|
|
||||||
private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>();
|
|
||||||
|
|
||||||
private readonly IDatabaseProvider databaseProvider;
|
|
||||||
|
|
||||||
public UserRoleManager(IDatabaseProvider databaseProvider) {
|
|
||||||
this.databaseProvider = databaseProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
return await ctx.UserRoles
|
|
||||||
.Include(static ur => ur.Role)
|
|
||||||
.GroupBy(static ur => ur.UserGuid, static ur => ur.Role)
|
|
||||||
.ToDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ImmutableArray<RoleEntity>> GetUserRoles(UserEntity user) {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
return await ctx.UserRoles
|
|
||||||
.Include(static ur => ur.Role)
|
|
||||||
.Where(ur => ur.UserGuid == user.UserGuid)
|
|
||||||
.Select(static ur => ur.Role)
|
|
||||||
.AsAsyncEnumerable()
|
|
||||||
.ToImmutableArrayAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ImmutableHashSet<Guid>> GetUserRoleGuids(UserEntity user) {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
return await ctx.UserRoles
|
|
||||||
.Where(ur => ur.UserGuid == user.UserGuid)
|
|
||||||
.Select(static ur => ur.RoleGuid)
|
|
||||||
.AsAsyncEnumerable()
|
|
||||||
.ToImmutableSetAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> Add(UserEntity user, RoleEntity role) {
|
|
||||||
try {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Information("Added user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> Remove(UserEntity user, RoleEntity role) {
|
|
||||||
try {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Information("Removed user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Users;
|
|
||||||
|
|
||||||
public abstract record SetUserPasswordError {
|
|
||||||
private SetUserPasswordError() {}
|
|
||||||
|
|
||||||
public sealed record UserNotFound : SetUserPasswordError;
|
|
||||||
|
|
||||||
public sealed record PasswordIsInvalid(ImmutableArray<PasswordRequirementViolation> Violations) : SetUserPasswordError;
|
|
||||||
|
|
||||||
public sealed record UnknownError : SetUserPasswordError;
|
|
||||||
}
|
|
@ -0,0 +1,34 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Services.Users;
|
||||||
|
|
||||||
|
sealed class UserLoginManager {
|
||||||
|
private const int SessionIdBytes = 20;
|
||||||
|
private readonly ConcurrentDictionary<string, List<ImmutableArray<byte>>> sessionTokensByUsername = new ();
|
||||||
|
|
||||||
|
private readonly UserManager userManager;
|
||||||
|
private readonly PermissionManager permissionManager;
|
||||||
|
|
||||||
|
public UserLoginManager(UserManager userManager, PermissionManager permissionManager) {
|
||||||
|
this.userManager = userManager;
|
||||||
|
this.permissionManager = permissionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LogInSuccess?> LogIn(string username, string password) {
|
||||||
|
var user = await userManager.GetAuthenticated(username, password);
|
||||||
|
if (user == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = ImmutableArray.Create(RandomNumberGenerator.GetBytes(SessionIdBytes));
|
||||||
|
var sessionTokens = sessionTokensByUsername.GetOrAdd(username, static _ => new List<ImmutableArray<byte>>());
|
||||||
|
lock (sessionTokens) {
|
||||||
|
sessionTokens.Add(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LogInSuccess(user.UserGuid, await permissionManager.FetchPermissionsForUserId(user.UserGuid), token);
|
||||||
|
}
|
||||||
|
}
|
@ -1,136 +1,97 @@
|
|||||||
using System.Collections.Immutable;
|
using Phantom.Common.Data.Web.Users;
|
||||||
using System.Security.Claims;
|
using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults;
|
||||||
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.Utils.Collections;
|
using Phantom.Controller.Database.Repositories;
|
||||||
using Phantom.Utils.Tasks;
|
using Serilog;
|
||||||
using ILogger = Serilog.ILogger;
|
|
||||||
|
|
||||||
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 IDbContextProvider dbProvider;
|
||||||
|
|
||||||
private readonly IDatabaseProvider databaseProvider;
|
public UserManager(IDbContextProvider dbProvider) {
|
||||||
|
this.dbProvider = dbProvider;
|
||||||
public UserManager(IDatabaseProvider databaseProvider) {
|
|
||||||
this.databaseProvider = databaseProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) {
|
|
||||||
if (user.Identity is not { IsAuthenticated: true }) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var claim = user.FindFirst(ClaimTypes.NameIdentifier);
|
|
||||||
if (claim == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Guid.TryParse(claim.Value, out var guid) ? guid : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ImmutableArray<UserEntity>> GetAll() {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
return await ctx.Users.AsAsyncEnumerable().ToImmutableArrayAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
return await ctx.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<UserEntity?> GetByName(string username) {
|
|
||||||
await using var ctx = databaseProvider.Provide();
|
|
||||||
return await ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UserEntity?> GetAuthenticated(string username, string password) {
|
public async Task<UserEntity?> GetAuthenticated(string username, string password) {
|
||||||
await using var ctx = databaseProvider.Provide();
|
await using var db = dbProvider.Lazy();
|
||||||
var user = await ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
|
var repository = new UserRepository(db);
|
||||||
return user != null && UserPasswords.Verify(user, password) ? user : null;
|
|
||||||
|
var user = await repository.GetByName(username);
|
||||||
|
return user != null && UserPasswords.Verify(password, user.PasswordHash) ? user : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) {
|
public async Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministrator(string username, string password) {
|
||||||
if (string.IsNullOrWhiteSpace(username)) {
|
await using var db = dbProvider.Lazy();
|
||||||
return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameIsEmpty());
|
var repository = new UserRepository(db);
|
||||||
}
|
|
||||||
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 {
|
try {
|
||||||
await using var ctx = databaseProvider.Provide();
|
bool wasCreated;
|
||||||
|
|
||||||
if (await ctx.Users.AnyAsync(user => user.Name == username)) {
|
var user = await repository.GetByName(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) {
|
|
||||||
UserEntity foundUser;
|
|
||||||
|
|
||||||
await using (var ctx = databaseProvider.Provide()) {
|
|
||||||
var user = await ctx.Users.FindAsync(guid);
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound());
|
var result = await repository.CreateUser(username, password);
|
||||||
}
|
if (result) {
|
||||||
|
user = result.Value;
|
||||||
foundUser = user;
|
wasCreated = true;
|
||||||
try {
|
}
|
||||||
var requirementViolations = UserPasswords.CheckRequirements(password);
|
else {
|
||||||
if (!requirementViolations.IsEmpty) {
|
return new CreationFailed(result.Error);
|
||||||
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
UserPasswords.Set(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());
|
|
||||||
}
|
}
|
||||||
}
|
else {
|
||||||
|
var result = repository.SetUserPassword(user, password);
|
||||||
|
if (!result) {
|
||||||
|
return new UpdatingFailed(result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
wasCreated = false;
|
||||||
|
}
|
||||||
|
|
||||||
Logger.Information("Changed password for user \"{Name}\" (GUID {Guid}).", foundUser.Name, foundUser.UserGuid);
|
var role = await new RoleRepository(db).GetByGuid(Role.Administrator.Guid);
|
||||||
return Result.Ok<SetUserPasswordError>();
|
if (role == null) {
|
||||||
|
return new AddingToRoleFailed();
|
||||||
|
}
|
||||||
|
|
||||||
|
await new UserRoleRepository(db).Add(user, role);
|
||||||
|
await db.Ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
|
||||||
|
if (wasCreated) {
|
||||||
|
Logger.Information("Created administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Logger.Information("Updated administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Success(user.ToUserInfo());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Logger.Error(e, "Could not create or update administrator user \"{Username}\".", username);
|
||||||
|
return new UnknownError();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DeleteUserResult> DeleteByGuid(Guid guid) {
|
public async Task<DeleteUserResult> DeleteByGuid(Guid guid) {
|
||||||
await using var ctx = databaseProvider.Provide();
|
await using var db = dbProvider.Lazy();
|
||||||
var user = await ctx.Users.FindAsync(guid);
|
var repository = new UserRepository(db);
|
||||||
|
|
||||||
|
var user = await repository.GetByGuid(guid);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return DeleteUserResult.NotFound;
|
return DeleteUserResult.NotFound;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ctx.Users.Remove(user);
|
repository.DeleteUser(user);
|
||||||
await ctx.SaveChangesAsync();
|
await db.Ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
Logger.Information("Deleted user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
||||||
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 \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
||||||
return DeleteUserResult.Failed;
|
return DeleteUserResult.Failed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,38 +0,0 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
using Phantom.Controller.Database.Entities;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Users;
|
|
||||||
|
|
||||||
internal static class UserPasswords {
|
|
||||||
private const int MinimumLength = 16;
|
|
||||||
|
|
||||||
public static ImmutableArray<PasswordRequirementViolation> CheckRequirements(string password) {
|
|
||||||
var violations = ImmutableArray.CreateBuilder<PasswordRequirementViolation>();
|
|
||||||
|
|
||||||
if (password.Length < MinimumLength) {
|
|
||||||
violations.Add(new PasswordRequirementViolation.TooShort(MinimumLength));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!password.Any(char.IsLower)) {
|
|
||||||
violations.Add(new PasswordRequirementViolation.LowercaseLetterRequired());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!password.Any(char.IsUpper)) {
|
|
||||||
violations.Add(new PasswordRequirementViolation.UppercaseLetterRequired());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!password.Any(char.IsDigit)) {
|
|
||||||
violations.Add(new PasswordRequirementViolation.DigitRequired());
|
|
||||||
}
|
|
||||||
|
|
||||||
return violations.ToImmutable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Set(UserEntity user, string password) {
|
|
||||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool Verify(UserEntity user, string password) {
|
|
||||||
return BCrypt.Net.BCrypt.Verify(password, user.PasswordHash);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,5 +1,5 @@
|
|||||||
using NetMQ;
|
using NetMQ;
|
||||||
using Phantom.Common.Data.Agent;
|
using Phantom.Common.Data;
|
||||||
|
|
||||||
namespace Phantom.Controller;
|
namespace Phantom.Controller;
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
using NetMQ;
|
using NetMQ;
|
||||||
using Phantom.Common.Data.Agent;
|
using Phantom.Common.Data;
|
||||||
using Phantom.Common.Logging;
|
using Phantom.Common.Logging;
|
||||||
using Phantom.Utils.Cryptography;
|
using Phantom.Utils.Cryptography;
|
||||||
using Phantom.Utils.IO;
|
using Phantom.Utils.IO;
|
||||||
|
@ -52,7 +52,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString);
|
var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString);
|
||||||
var controllerServices = new ControllerServices(dbContextFactory, agentKeyData.AuthToken, shutdownCancellationToken);
|
var controllerServices = new ControllerServices(dbContextFactory, agentKeyData.AuthToken, webKeyData.AuthToken, shutdownCancellationToken);
|
||||||
|
|
||||||
PhantomLogger.Root.InformationHeading("Launching Phantom Panel server...");
|
PhantomLogger.Root.InformationHeading("Launching Phantom Panel server...");
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -149,8 +149,8 @@ The repository includes a [Rider](https://www.jetbrains.com/rider/) projects wit
|
|||||||
- `Controller` starts the Controller.
|
- `Controller` starts the Controller.
|
||||||
- `Web` starts the Web server.
|
- `Web` starts the Web server.
|
||||||
- `Agent 1`, `Agent 2`, `Agent 3` start one of the Agents.
|
- `Agent 1`, `Agent 2`, `Agent 3` start one of the Agents.
|
||||||
- `Controller + Agent` starts the Controller and Agent 1.
|
- `Controller + Web + Agent` starts the Controller and Agent 1.
|
||||||
- `Controller + Agent x3` starts the Controller and Agent 1, 2, and 3.
|
- `Controller + Web + Agent x3` starts the Controller and Agent 1, 2, and 3.
|
||||||
|
|
||||||
## Bootstrap
|
## Bootstrap
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
namespace Phantom.Utils.Rpc.Message;
|
namespace Phantom.Utils.Rpc.Message;
|
||||||
|
|
||||||
public interface IReply {
|
public interface IReply {
|
||||||
uint SequenceId { get; }
|
uint SequenceId { get; }
|
||||||
|
@ -20,26 +20,36 @@ public sealed class MessageReplyTracker {
|
|||||||
return sequenceId;
|
return sequenceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TReply?> WaitForReply<TReply>(uint sequenceId, TimeSpan waitForReplyTime, CancellationToken cancellationToken) where TReply : class {
|
public async Task<TReply> WaitForReply<TReply>(uint sequenceId, TimeSpan waitForReplyTime, CancellationToken cancellationToken) {
|
||||||
if (!replyTasks.TryGetValue(sequenceId, out var completionSource)) {
|
if (!replyTasks.TryGetValue(sequenceId, out var completionSource)) {
|
||||||
logger.Warning("No reply callback for id {SequenceId}.", sequenceId);
|
logger.Warning("No reply callback for id {SequenceId}.", sequenceId);
|
||||||
return null;
|
throw new ArgumentException("No reply callback for id: " + sequenceId, nameof(sequenceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
byte[] replyBytes = await completionSource.Task.WaitAsync(waitForReplyTime, cancellationToken);
|
byte[] replyBytes = await completionSource.Task.WaitAsync(waitForReplyTime, cancellationToken);
|
||||||
return MessageSerializer.Deserialize<TReply>(replyBytes);
|
return MessageSerializer.Deserialize<TReply>(replyBytes);
|
||||||
} catch (TimeoutException) {
|
} catch (TimeoutException) {
|
||||||
return null;
|
logger.Debug("Timed out waiting for reply with id {SequenceId}.", sequenceId);
|
||||||
|
throw;
|
||||||
} catch (OperationCanceledException) {
|
} catch (OperationCanceledException) {
|
||||||
return null;
|
logger.Debug("Cancelled waiting for reply with id {SequenceId}.", sequenceId);
|
||||||
|
throw;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.Warning(e, "Error processing reply with id {SequenceId}.", sequenceId);
|
logger.Warning(e, "Error processing reply with id {SequenceId}.", sequenceId);
|
||||||
return null;
|
throw;
|
||||||
} finally {
|
} finally {
|
||||||
ForgetReply(sequenceId);
|
ForgetReply(sequenceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<TReply?> TryWaitForReply<TReply>(uint sequenceId, TimeSpan waitForReplyTime, CancellationToken cancellationToken) where TReply : class {
|
||||||
|
try {
|
||||||
|
return await WaitForReply<TReply>(sequenceId, waitForReplyTime, cancellationToken);
|
||||||
|
} catch (Exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void ForgetReply(uint sequenceId) {
|
public void ForgetReply(uint sequenceId) {
|
||||||
if (replyTasks.TryRemove(sequenceId, out var task)) {
|
if (replyTasks.TryRemove(sequenceId, out var task)) {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
using NetMQ.Sockets;
|
using NetMQ.Sockets;
|
||||||
using Phantom.Utils.Rpc.Message;
|
using Phantom.Utils.Rpc.Message;
|
||||||
|
|
||||||
namespace Phantom.Utils.Rpc;
|
namespace Phantom.Utils.Rpc;
|
||||||
|
|
||||||
public sealed class RpcConnectionToServer<TListener> {
|
public sealed class RpcConnectionToServer<TListener> {
|
||||||
private readonly ClientSocket socket;
|
private readonly ClientSocket socket;
|
||||||
@ -22,7 +22,7 @@ public sealed class RpcConnectionToServer<TListener> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> where TReply : class {
|
public async Task<TReply?> TrySend<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> where TReply : class {
|
||||||
var sequenceId = replyTracker.RegisterReply();
|
var sequenceId = replyTracker.RegisterReply();
|
||||||
|
|
||||||
var bytes = messageRegistry.Write<TMessage, TReply>(sequenceId, message).ToArray();
|
var bytes = messageRegistry.Write<TMessage, TReply>(sequenceId, message).ToArray();
|
||||||
@ -31,6 +31,19 @@ public sealed class RpcConnectionToServer<TListener> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await socket.SendAsync(bytes);
|
||||||
|
return await replyTracker.TryWaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TReply> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> {
|
||||||
|
var sequenceId = replyTracker.RegisterReply();
|
||||||
|
|
||||||
|
var bytes = messageRegistry.Write<TMessage, TReply>(sequenceId, message).ToArray();
|
||||||
|
if (bytes.Length == 0) {
|
||||||
|
replyTracker.ForgetReply(sequenceId);
|
||||||
|
throw new ArgumentException("Could not write message.", nameof(message));
|
||||||
|
}
|
||||||
|
|
||||||
await socket.SendAsync(bytes);
|
await socket.SendAsync(bytes);
|
||||||
return await replyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
|
return await replyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
|
||||||
}
|
}
|
||||||
|
@ -2,17 +2,57 @@
|
|||||||
|
|
||||||
public abstract record Result<TValue, TError> {
|
public abstract record Result<TValue, TError> {
|
||||||
private Result() {}
|
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> {
|
public abstract record Result<TError> {
|
||||||
private Result() {}
|
private Result() {}
|
||||||
|
|
||||||
|
public abstract TError Error { get; init; }
|
||||||
|
|
||||||
|
public static implicit operator Result<TError>(TError error) {
|
||||||
|
return new Fail(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static implicit operator bool(Result<TError> result) {
|
||||||
|
return result is Ok;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record Ok : Result<TError> {
|
public sealed record Ok : Result<TError> {
|
||||||
internal static Ok Instance { get; } = new ();
|
internal static Ok Instance { get; } = new ();
|
||||||
|
|
||||||
|
public override TError Error {
|
||||||
|
get => throw new InvalidOperationException("Attempted to get error from Ok result.");
|
||||||
|
init {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record Fail(TError Error) : Result<TError>;
|
public sealed record Fail(TError Error) : Result<TError>;
|
||||||
|
@ -1,77 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
||||||
using Phantom.Common.Logging;
|
|
||||||
using Phantom.Controller.Services.Users;
|
|
||||||
using Phantom.Utils.Cryptography;
|
|
||||||
using Phantom.Web.Identity.Interfaces;
|
|
||||||
using ILogger = Serilog.ILogger;
|
|
||||||
|
|
||||||
namespace Phantom.Web.Identity.Authentication;
|
|
||||||
|
|
||||||
public sealed class PhantomLoginManager {
|
|
||||||
private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginManager>();
|
|
||||||
|
|
||||||
public static bool IsAuthenticated(ClaimsPrincipal user) {
|
|
||||||
return user.Identity is { IsAuthenticated: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly INavigation navigation;
|
|
||||||
private readonly UserManager userManager;
|
|
||||||
private readonly PhantomLoginStore loginStore;
|
|
||||||
private readonly ILoginEvents loginEvents;
|
|
||||||
|
|
||||||
public PhantomLoginManager(INavigation navigation, UserManager userManager, PhantomLoginStore loginStore, ILoginEvents loginEvents) {
|
|
||||||
this.navigation = navigation;
|
|
||||||
this.userManager = userManager;
|
|
||||||
this.loginStore = loginStore;
|
|
||||||
this.loginEvents = loginEvents;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> SignIn(string username, string password, string? returnUrl = null) {
|
|
||||||
if (await userManager.GetAuthenticated(username, password) == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Debug("Created login token for {Username}.", username);
|
|
||||||
|
|
||||||
string token = TokenGenerator.Create(60);
|
|
||||||
loginStore.Add(token, username, password, returnUrl ?? string.Empty);
|
|
||||||
navigation.NavigateTo("login" + QueryString.Create("token", token), forceLoad: true);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task<SignInResult?> ProcessToken(string token) {
|
|
||||||
var entry = loginStore.Pop(token);
|
|
||||||
if (entry == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await userManager.GetAuthenticated(entry.Username, entry.Password);
|
|
||||||
if (user == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Information("Successful login for {Username}.", user.Name);
|
|
||||||
loginEvents.UserLoggedIn(user);
|
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
||||||
identity.AddClaim(new Claim(ClaimTypes.Name, user.Name));
|
|
||||||
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.UserGuid.ToString()));
|
|
||||||
|
|
||||||
var authenticationProperties = new AuthenticationProperties {
|
|
||||||
IsPersistent = true
|
|
||||||
};
|
|
||||||
|
|
||||||
return new SignInResult(new ClaimsPrincipal(identity), authenticationProperties, entry.ReturnUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed record SignInResult(ClaimsPrincipal ClaimsPrincipal, AuthenticationProperties AuthenticationProperties, string ReturnUrl);
|
|
||||||
|
|
||||||
internal void OnSignedOut(ClaimsPrincipal user) {
|
|
||||||
if (UserManager.GetAuthenticatedUserId(user) is {} userGuid) {
|
|
||||||
loginEvents.UserLoggedOut(userGuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,62 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using Phantom.Common.Logging;
|
|
||||||
using Phantom.Utils.Tasks;
|
|
||||||
using ILogger = Serilog.ILogger;
|
|
||||||
|
|
||||||
namespace Phantom.Web.Identity.Authentication;
|
|
||||||
|
|
||||||
public sealed class PhantomLoginStore {
|
|
||||||
private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginStore>();
|
|
||||||
private static readonly TimeSpan ExpirationTime = TimeSpan.FromMinutes(1);
|
|
||||||
|
|
||||||
internal static Func<IServiceProvider, PhantomLoginStore> Create(CancellationToken cancellationToken) {
|
|
||||||
return provider => new PhantomLoginStore(provider.GetRequiredService<TaskManager>(), cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, LoginEntry> loginEntries = new ();
|
|
||||||
private readonly CancellationToken cancellationToken;
|
|
||||||
|
|
||||||
private PhantomLoginStore(TaskManager taskManager, CancellationToken cancellationToken) {
|
|
||||||
this.cancellationToken = cancellationToken;
|
|
||||||
taskManager.Run("Web login entry expiration loop", RunExpirationLoop);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunExpirationLoop() {
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
await Task.Delay(ExpirationTime, cancellationToken);
|
|
||||||
|
|
||||||
foreach (var (token, entry) in loginEntries) {
|
|
||||||
if (entry.IsExpired) {
|
|
||||||
Logger.Debug("Expired login entry for {Username}.", entry.Username);
|
|
||||||
loginEntries.TryRemove(token, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
Logger.Information("Expiration loop stopped.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void Add(string token, string username, string password, string returnUrl) {
|
|
||||||
loginEntries[token] = new LoginEntry(username, password, returnUrl, Stopwatch.StartNew());
|
|
||||||
}
|
|
||||||
|
|
||||||
internal LoginEntry? Pop(string token) {
|
|
||||||
if (!loginEntries.TryRemove(token, out var entry)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.IsExpired) {
|
|
||||||
Logger.Debug("Expired login entry for {Username}.", entry.Username);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed record LoginEntry(string Username, string Password, string ReturnUrl, Stopwatch AddedTime) {
|
|
||||||
public bool IsExpired => AddedTime.Elapsed >= ExpirationTime;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
using Phantom.Controller.Database.Entities;
|
|
||||||
|
|
||||||
namespace Phantom.Web.Identity.Interfaces;
|
|
||||||
|
|
||||||
public interface ILoginEvents {
|
|
||||||
void UserLoggedIn(UserEntity user);
|
|
||||||
void UserLoggedOut(Guid userGuid);
|
|
||||||
}
|
|
@ -1,46 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Components.Server;
|
|
||||||
using Phantom.Web.Identity.Authentication;
|
|
||||||
using Phantom.Web.Identity.Authorization;
|
|
||||||
|
|
||||||
namespace Phantom.Web.Identity;
|
|
||||||
|
|
||||||
public static class PhantomIdentityExtensions {
|
|
||||||
public static void AddPhantomIdentity(this IServiceCollection services, CancellationToken cancellationToken) {
|
|
||||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(ConfigureIdentityCookie);
|
|
||||||
services.AddAuthorization(ConfigureAuthorization);
|
|
||||||
|
|
||||||
services.AddSingleton(PhantomLoginStore.Create(cancellationToken));
|
|
||||||
services.AddScoped<PhantomLoginManager>();
|
|
||||||
|
|
||||||
services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>();
|
|
||||||
services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void UsePhantomIdentity(this IApplicationBuilder application) {
|
|
||||||
application.UseAuthentication();
|
|
||||||
application.UseAuthorization();
|
|
||||||
application.UseWhen(PhantomIdentityMiddleware.AcceptsPath, static app => app.UseMiddleware<PhantomIdentityMiddleware>());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ConfigureIdentityCookie(CookieAuthenticationOptions o) {
|
|
||||||
o.Cookie.Name = "Phantom.Identity";
|
|
||||||
o.Cookie.HttpOnly = true;
|
|
||||||
o.Cookie.SameSite = SameSiteMode.Lax;
|
|
||||||
|
|
||||||
o.ExpireTimeSpan = TimeSpan.FromDays(30);
|
|
||||||
o.SlidingExpiration = true;
|
|
||||||
|
|
||||||
o.LoginPath = PhantomIdentityMiddleware.LoginPath;
|
|
||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Phantom.Web.Identity.Authentication;
|
|
||||||
using Phantom.Web.Identity.Interfaces;
|
|
||||||
|
|
||||||
namespace Phantom.Web.Identity;
|
|
||||||
|
|
||||||
sealed class PhantomIdentityMiddleware {
|
|
||||||
public const string LoginPath = "/login";
|
|
||||||
public const string LogoutPath = "/logout";
|
|
||||||
|
|
||||||
public static bool AcceptsPath(HttpContext context) {
|
|
||||||
var path = context.Request.Path;
|
|
||||||
return path == LoginPath || path == LogoutPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly RequestDelegate next;
|
|
||||||
|
|
||||||
public PhantomIdentityMiddleware(RequestDelegate next) {
|
|
||||||
this.next = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
|
||||||
public async Task InvokeAsync(HttpContext context, INavigation navigation, PhantomLoginManager loginManager) {
|
|
||||||
var path = context.Request.Path;
|
|
||||||
if (path == LoginPath && context.Request.Query.TryGetValue("token", out var tokens) && tokens[0] is {} token && await loginManager.ProcessToken(token) is {} result) {
|
|
||||||
await context.SignInAsync(result.ClaimsPrincipal, result.AuthenticationProperties);
|
|
||||||
context.Response.Redirect(navigation.BasePath + result.ReturnUrl);
|
|
||||||
}
|
|
||||||
else if (path == LogoutPath) {
|
|
||||||
loginManager.OnSignedOut(context.User);
|
|
||||||
await context.SignOutAsync();
|
|
||||||
context.Response.Redirect(navigation.BasePath);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await next.Invoke(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,90 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components.Server;
|
||||||
|
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
using ILogger = Serilog.ILogger;
|
||||||
|
|
||||||
|
namespace Phantom.Web.Services.Authentication;
|
||||||
|
|
||||||
|
public sealed class PhantomAuthenticationStateProvider : ServerAuthenticationStateProvider {
|
||||||
|
private const string SessionTokenKey = "PhantomSession";
|
||||||
|
|
||||||
|
private static readonly ILogger Logger = PhantomLogger.Create<PhantomAuthenticationStateProvider>();
|
||||||
|
|
||||||
|
private readonly PhantomLoginSessions loginSessions;
|
||||||
|
private readonly ProtectedLocalStorage localStorage;
|
||||||
|
private bool isLoaded;
|
||||||
|
|
||||||
|
public PhantomAuthenticationStateProvider(PhantomLoginSessions loginSessions, ProtectedLocalStorage localStorage) {
|
||||||
|
this.loginSessions = loginSessions;
|
||||||
|
this.localStorage = localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync() {
|
||||||
|
if (isLoaded) {
|
||||||
|
return await base.GetAuthenticationStateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalStorageEntry? stored;
|
||||||
|
try {
|
||||||
|
stored = await GetLocalStorageEntry();
|
||||||
|
} catch (InvalidOperationException) {
|
||||||
|
stored = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored != null) {
|
||||||
|
var session = loginSessions.Find(stored.UserGuid, stored.Token);
|
||||||
|
if (session != null) {
|
||||||
|
SetLoadedSession(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await base.GetAuthenticationStateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record LocalStorageEntry(Guid UserGuid, ImmutableArray<byte> Token);
|
||||||
|
|
||||||
|
private async Task<LocalStorageEntry?> GetLocalStorageEntry() {
|
||||||
|
try {
|
||||||
|
var result = await localStorage.GetAsync<LocalStorageEntry>(SessionTokenKey);
|
||||||
|
return result.Success ? result.Value : null;
|
||||||
|
} catch (InvalidOperationException) {
|
||||||
|
return null;
|
||||||
|
} catch (CryptographicException) {
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Logger.Error(e, "Could not read local storage entry.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetLoadedSession(UserSession session) {
|
||||||
|
isLoaded = true;
|
||||||
|
SetAuthenticationState(Task.FromResult(new AuthenticationState(session.AsClaimsPrincipal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task HandleLogin(UserSession session) {
|
||||||
|
await localStorage.SetAsync(SessionTokenKey, new LocalStorageEntry(session.UserGuid, session.Token));
|
||||||
|
loginSessions.Add(session);
|
||||||
|
SetLoadedSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task HandleLogout() {
|
||||||
|
if (!isLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await localStorage.DeleteAsync(SessionTokenKey);
|
||||||
|
|
||||||
|
var stored = await GetLocalStorageEntry();
|
||||||
|
if (stored != null) {
|
||||||
|
loginSessions.Remove(stored.UserGuid, stored.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoaded = false;
|
||||||
|
SetAuthenticationState(Task.FromResult(new AuthenticationState(new ClaimsPrincipal())));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
using Phantom.Common.Messages.Web.ToController;
|
||||||
|
using Phantom.Web.Services.Rpc;
|
||||||
|
using ILogger = Serilog.ILogger;
|
||||||
|
|
||||||
|
namespace Phantom.Web.Services.Authentication;
|
||||||
|
|
||||||
|
public sealed class PhantomLoginManager {
|
||||||
|
private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginManager>();
|
||||||
|
|
||||||
|
public const string AuthenticationType = "Phantom";
|
||||||
|
|
||||||
|
private readonly INavigation navigation;
|
||||||
|
private readonly PhantomAuthenticationStateProvider authenticationStateProvider;
|
||||||
|
private readonly ControllerConnection controllerConnection;
|
||||||
|
|
||||||
|
public PhantomLoginManager(INavigation navigation, PhantomAuthenticationStateProvider authenticationStateProvider, ControllerConnection controllerConnection) {
|
||||||
|
this.navigation = navigation;
|
||||||
|
this.authenticationStateProvider = authenticationStateProvider;
|
||||||
|
this.controllerConnection = controllerConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SignIn(string username, string password, string? returnUrl = null) {
|
||||||
|
LogInSuccess? success;
|
||||||
|
try {
|
||||||
|
success = await controllerConnection.Send<LogIn, LogInSuccess?>(new LogIn(username, password), TimeSpan.FromSeconds(30));
|
||||||
|
} catch (Exception e) {
|
||||||
|
Logger.Error(e, "Could not log in {Username}.", username);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Information("Successfully logged in {Username}.", username);
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(AuthenticationType);
|
||||||
|
identity.AddClaim(new Claim(ClaimTypes.Name, username));
|
||||||
|
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, success.UserGuid.ToString()));
|
||||||
|
|
||||||
|
await authenticationStateProvider.HandleLogin(new UserSession(success.UserGuid, username, success.Token));
|
||||||
|
await navigation.NavigateTo(returnUrl ?? string.Empty);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SignOut() {
|
||||||
|
await navigation.NavigateTo(string.Empty);
|
||||||
|
await authenticationStateProvider.HandleLogout();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
namespace Phantom.Web.Services.Authentication;
|
||||||
|
|
||||||
|
public sealed class PhantomLoginSessions {
|
||||||
|
private readonly ConcurrentDictionary<Guid, List<UserSession>> userSessions = new ();
|
||||||
|
|
||||||
|
internal void Add(UserSession session) {
|
||||||
|
var sessions = userSessions.GetOrAdd(session.UserGuid, static _ => new List<UserSession>());
|
||||||
|
|
||||||
|
lock (sessions) {
|
||||||
|
RemoveSessionInternal(sessions, session.Token);
|
||||||
|
sessions.Add(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal UserSession? Find(Guid userGuid, ImmutableArray<byte> token) {
|
||||||
|
if (!userSessions.TryGetValue(userGuid, out var sessions)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (sessions) {
|
||||||
|
int index = FindSessionInternal(sessions, token);
|
||||||
|
return index == -1 ? null : sessions[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Remove(Guid userGuid, ImmutableArray<byte> token) {
|
||||||
|
if (!userSessions.TryGetValue(userGuid, out var sessions)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (sessions) {
|
||||||
|
RemoveSessionInternal(sessions, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FindSessionInternal(List<UserSession> sessions, ImmutableArray<byte> token) {
|
||||||
|
return sessions.FindIndex(s => s.TokenEquals(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveSessionInternal(List<UserSession> sessions, ImmutableArray<byte> token) {
|
||||||
|
int index = FindSessionInternal(sessions, token);
|
||||||
|
if (index != -1) {
|
||||||
|
sessions.RemoveAt(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
Web/Phantom.Web.Services/Authentication/UserSession.cs
Normal file
32
Web/Phantom.Web.Services/Authentication/UserSession.cs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Phantom.Web.Services.Authentication;
|
||||||
|
|
||||||
|
sealed class UserSession {
|
||||||
|
public Guid UserGuid { get; }
|
||||||
|
public string Username { get; }
|
||||||
|
public ImmutableArray<byte> Token { get; }
|
||||||
|
|
||||||
|
public ClaimsPrincipal AsClaimsPrincipal {
|
||||||
|
get {
|
||||||
|
var identity = new ClaimsIdentity(PhantomLoginManager.AuthenticationType);
|
||||||
|
|
||||||
|
identity.AddClaim(new Claim(ClaimTypes.Name, Username));
|
||||||
|
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, UserGuid.ToString()));
|
||||||
|
|
||||||
|
return new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserSession(Guid userGuid, string username, ImmutableArray<byte> token) {
|
||||||
|
UserGuid = userGuid;
|
||||||
|
Username = username;
|
||||||
|
Token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TokenEquals(ImmutableArray<byte> other) {
|
||||||
|
return CryptographicOperations.FixedTimeEquals(Token.AsSpan(), other.AsSpan());
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Phantom.Web.Identity.Authorization;
|
namespace Phantom.Web.Services.Authorization;
|
||||||
|
|
||||||
sealed class PermissionBasedPolicyHandler : AuthorizationHandler<PermissionBasedPolicyRequirement> {
|
sealed class PermissionBasedPolicyHandler : AuthorizationHandler<PermissionBasedPolicyRequirement> {
|
||||||
private readonly PermissionManager permissionManager;
|
private readonly PermissionManager permissionManager;
|
@ -1,6 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Phantom.Web.Identity.Data;
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
namespace Phantom.Web.Identity.Authorization;
|
namespace Phantom.Web.Services.Authorization;
|
||||||
|
|
||||||
sealed record PermissionBasedPolicyRequirement(Permission Permission) : IAuthorizationRequirement;
|
sealed record PermissionBasedPolicyRequirement(Permission Permission) : IAuthorizationRequirement;
|
15
Web/Phantom.Web.Services/Authorization/PermissionManager.cs
Normal file
15
Web/Phantom.Web.Services/Authorization/PermissionManager.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Phantom.Common.Data.Web.Users;
|
||||||
|
|
||||||
|
namespace Phantom.Web.Services.Authorization;
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
public class PermissionManager {
|
||||||
|
public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
@inject PermissionManager PermissionManager
|
@inject PermissionManager PermissionManager
|
||||||
|
|
||||||
<AuthorizeView>
|
<AuthorizeView>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user