1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2025-05-10 08:34:11 +02:00

Replace ASP.NET Identity with a custom solution

This commit is contained in:
chylex 2023-10-08 15:33:14 +02:00
parent 956f1e779b
commit 346eab0b1b
Signed by: chylex
GPG Key ID: 4DE42C8F19A80548
50 changed files with 2027 additions and 657 deletions

View File

@ -3,7 +3,7 @@
<ItemGroup>
<PackageReference Update="Microsoft.AspNetCore.Components.Authorization" Version="7.0.11" />
<PackageReference Update="Microsoft.AspNetCore.Components.Web" Version="7.0.11" />
<PackageReference Update="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.11" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Relational" Version="7.0.11" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Tools" Version="7.0.11" />
<PackageReference Update="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.11" />
</ItemGroup>

View File

@ -0,0 +1,180 @@
// <auto-generated />
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Phantom.Server.Database;
#nullable disable
namespace Phantom.Server.Database.Postgres.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20231008122637_ReplaceIdentity")]
partial class ReplaceIdentity
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Phantom.Server.Database.Entities.AgentEntity", b =>
{
b.Property<Guid>("AgentGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BuildVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("MaxInstances")
.HasColumnType("integer");
b.Property<ushort>("MaxMemory")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ProtocolVersion")
.HasColumnType("integer");
b.HasKey("AgentGuid");
b.ToTable("Agents", "agents");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.AuditLogEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("UserGuid")
.HasColumnType("uuid");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("AuditLog", "system");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.EventLogEntity", b =>
{
b.Property<Guid>("EventGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentGuid")
.HasColumnType("uuid");
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("EventGuid");
b.ToTable("EventLog", "system");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.InstanceEntity", b =>
{
b.Property<Guid>("InstanceGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("JavaRuntimeGuid")
.HasColumnType("uuid");
b.Property<string>("JvmArguments")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("LaunchAutomatically")
.HasColumnType("boolean");
b.Property<ushort>("MemoryAllocation")
.HasColumnType("integer");
b.Property<string>("MinecraftServerKind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MinecraftVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RconPort")
.HasColumnType("integer");
b.Property<int>("ServerPort")
.HasColumnType("integer");
b.HasKey("InstanceGuid");
b.ToTable("Instances", "agents");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.PermissionEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Permissions", "identity");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,373 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Phantom.Server.Database.Postgres.Migrations
{
/// <inheritdoc />
public partial class ReplaceIdentity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AuditLog_Users_UserId",
schema: "system",
table: "AuditLog");
migrationBuilder.DropTable(
name: "RoleClaims",
schema: "identity");
migrationBuilder.DropTable(
name: "RolePermissions",
schema: "identity");
migrationBuilder.DropTable(
name: "UserClaims",
schema: "identity");
migrationBuilder.DropTable(
name: "UserLogins",
schema: "identity");
migrationBuilder.DropTable(
name: "UserPermissions",
schema: "identity");
migrationBuilder.DropTable(
name: "UserRoles",
schema: "identity");
migrationBuilder.DropTable(
name: "UserTokens",
schema: "identity");
migrationBuilder.DropTable(
name: "Roles",
schema: "identity");
migrationBuilder.DropTable(
name: "Users",
schema: "identity");
migrationBuilder.DropIndex(
name: "IX_AuditLog_UserId",
schema: "system",
table: "AuditLog");
migrationBuilder.DropColumn(
name: "UserId",
schema: "system",
table: "AuditLog");
migrationBuilder.AddColumn<Guid>(
name: "UserGuid",
schema: "system",
table: "AuditLog",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "UserGuid",
schema: "system",
table: "AuditLog");
migrationBuilder.AddColumn<string>(
name: "UserId",
schema: "system",
table: "AuditLog",
type: "text",
nullable: true);
migrationBuilder.CreateTable(
name: "Roles",
schema: "identity",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Roles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
schema: "identity",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
PasswordHash = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RoleClaims",
schema: "identity",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true),
RoleId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_RoleClaims_Roles_RoleId",
column: x => x.RoleId,
principalSchema: "identity",
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RolePermissions",
schema: "identity",
columns: table => new
{
RoleId = table.Column<string>(type: "text", nullable: false),
PermissionId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RolePermissions", x => new { x.RoleId, x.PermissionId });
table.ForeignKey(
name: "FK_RolePermissions_Permissions_PermissionId",
column: x => x.PermissionId,
principalSchema: "identity",
principalTable: "Permissions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RolePermissions_Roles_RoleId",
column: x => x.RoleId,
principalSchema: "identity",
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserClaims",
schema: "identity",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserClaims", x => x.Id);
table.ForeignKey(
name: "FK_UserClaims_Users_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserLogins",
schema: "identity",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_UserLogins_Users_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserPermissions",
schema: "identity",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
PermissionId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserPermissions", x => new { x.UserId, x.PermissionId });
table.ForeignKey(
name: "FK_UserPermissions_Permissions_PermissionId",
column: x => x.PermissionId,
principalSchema: "identity",
principalTable: "Permissions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserPermissions_Users_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserRoles",
schema: "identity",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
RoleId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_UserRoles_Roles_RoleId",
column: x => x.RoleId,
principalSchema: "identity",
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserRoles_Users_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserTokens",
schema: "identity",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_UserTokens_Users_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AuditLog_UserId",
schema: "system",
table: "AuditLog",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_RoleClaims_RoleId",
schema: "identity",
table: "RoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_RolePermissions_PermissionId",
schema: "identity",
table: "RolePermissions",
column: "PermissionId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
schema: "identity",
table: "Roles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserClaims_UserId",
schema: "identity",
table: "UserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserLogins_UserId",
schema: "identity",
table: "UserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserPermissions_PermissionId",
schema: "identity",
table: "UserPermissions",
column: "PermissionId");
migrationBuilder.CreateIndex(
name: "IX_UserRoles_RoleId",
schema: "identity",
table: "UserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
schema: "identity",
table: "Users",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
schema: "identity",
table: "Users",
column: "NormalizedUserName",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_AuditLog_Users_UserId",
schema: "system",
table: "AuditLog",
column: "UserId",
principalSchema: "identity",
principalTable: "Users",
principalColumn: "Id");
}
}
}

View File

@ -0,0 +1,323 @@
// <auto-generated />
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Phantom.Server.Database;
#nullable disable
namespace Phantom.Server.Database.Postgres.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20231008123315_ReplaceIdentity2")]
partial class ReplaceIdentity2
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Phantom.Server.Database.Entities.AgentEntity", b =>
{
b.Property<Guid>("AgentGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BuildVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("MaxInstances")
.HasColumnType("integer");
b.Property<ushort>("MaxMemory")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ProtocolVersion")
.HasColumnType("integer");
b.HasKey("AgentGuid");
b.ToTable("Agents", "agents");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.AuditLogEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("UserGuid")
.HasColumnType("uuid");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("UserGuid");
b.ToTable("AuditLog", "system");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.EventLogEntity", b =>
{
b.Property<Guid>("EventGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentGuid")
.HasColumnType("uuid");
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("EventGuid");
b.ToTable("EventLog", "system");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.InstanceEntity", b =>
{
b.Property<Guid>("InstanceGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("JavaRuntimeGuid")
.HasColumnType("uuid");
b.Property<string>("JvmArguments")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("LaunchAutomatically")
.HasColumnType("boolean");
b.Property<ushort>("MemoryAllocation")
.HasColumnType("integer");
b.Property<string>("MinecraftServerKind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MinecraftVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RconPort")
.HasColumnType("integer");
b.Property<int>("ServerPort")
.HasColumnType("integer");
b.HasKey("InstanceGuid");
b.ToTable("Instances", "agents");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.PermissionEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Permissions", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.RoleEntity", b =>
{
b.Property<Guid>("RoleGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("RoleGuid");
b.ToTable("Roles", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.RolePermissionEntity", b =>
{
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("RoleGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("RolePermissions", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.UserEntity", b =>
{
b.Property<Guid>("UserGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("UserGuid");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.UserPermissionEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("UserGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("UserPermissions", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.UserRoleEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "RoleGuid");
b.HasIndex("RoleGuid");
b.ToTable("UserRoles", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.AuditLogEntity", b =>
{
b.HasOne("Phantom.Server.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("User");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.RolePermissionEntity", b =>
{
b.HasOne("Phantom.Server.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Server.Database.Entities.RoleEntity", null)
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Server.Database.Entities.UserPermissionEntity", b =>
{
b.HasOne("Phantom.Server.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Server.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Server.Database.Entities.UserRoleEntity", b =>
{
b.HasOne("Phantom.Server.Database.Entities.RoleEntity", "Role")
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Server.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,198 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Phantom.Server.Database.Postgres.Migrations
{
/// <inheritdoc />
public partial class ReplaceIdentity2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Roles",
schema: "identity",
columns: table => new
{
RoleGuid = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Roles", x => x.RoleGuid);
});
migrationBuilder.CreateTable(
name: "Users",
schema: "identity",
columns: table => new
{
UserGuid = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.UserGuid);
});
migrationBuilder.CreateTable(
name: "RolePermissions",
schema: "identity",
columns: table => new
{
RoleGuid = table.Column<Guid>(type: "uuid", nullable: false),
PermissionId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RolePermissions", x => new { x.RoleGuid, x.PermissionId });
table.ForeignKey(
name: "FK_RolePermissions_Permissions_PermissionId",
column: x => x.PermissionId,
principalSchema: "identity",
principalTable: "Permissions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_RolePermissions_Roles_RoleGuid",
column: x => x.RoleGuid,
principalSchema: "identity",
principalTable: "Roles",
principalColumn: "RoleGuid",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserPermissions",
schema: "identity",
columns: table => new
{
UserGuid = table.Column<Guid>(type: "uuid", nullable: false),
PermissionId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserPermissions", x => new { x.UserGuid, x.PermissionId });
table.ForeignKey(
name: "FK_UserPermissions_Permissions_PermissionId",
column: x => x.PermissionId,
principalSchema: "identity",
principalTable: "Permissions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserPermissions_Users_UserGuid",
column: x => x.UserGuid,
principalSchema: "identity",
principalTable: "Users",
principalColumn: "UserGuid",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserRoles",
schema: "identity",
columns: table => new
{
UserGuid = table.Column<Guid>(type: "uuid", nullable: false),
RoleGuid = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserRoles", x => new { x.UserGuid, x.RoleGuid });
table.ForeignKey(
name: "FK_UserRoles_Roles_RoleGuid",
column: x => x.RoleGuid,
principalSchema: "identity",
principalTable: "Roles",
principalColumn: "RoleGuid",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserRoles_Users_UserGuid",
column: x => x.UserGuid,
principalSchema: "identity",
principalTable: "Users",
principalColumn: "UserGuid",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AuditLog_UserGuid",
schema: "system",
table: "AuditLog",
column: "UserGuid");
migrationBuilder.CreateIndex(
name: "IX_RolePermissions_PermissionId",
schema: "identity",
table: "RolePermissions",
column: "PermissionId");
migrationBuilder.CreateIndex(
name: "IX_UserPermissions_PermissionId",
schema: "identity",
table: "UserPermissions",
column: "PermissionId");
migrationBuilder.CreateIndex(
name: "IX_UserRoles_RoleGuid",
schema: "identity",
table: "UserRoles",
column: "RoleGuid");
migrationBuilder.CreateIndex(
name: "IX_Users_Name",
schema: "identity",
table: "Users",
column: "Name",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_AuditLog_Users_UserGuid",
schema: "system",
table: "AuditLog",
column: "UserGuid",
principalSchema: "identity",
principalTable: "Users",
principalColumn: "UserGuid",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AuditLog_Users_UserGuid",
schema: "system",
table: "AuditLog");
migrationBuilder.DropTable(
name: "RolePermissions",
schema: "identity");
migrationBuilder.DropTable(
name: "UserPermissions",
schema: "identity");
migrationBuilder.DropTable(
name: "UserRoles",
schema: "identity");
migrationBuilder.DropTable(
name: "Roles",
schema: "identity");
migrationBuilder.DropTable(
name: "Users",
schema: "identity");
migrationBuilder.DropIndex(
name: "IX_AuditLog_UserGuid",
schema: "system",
table: "AuditLog");
}
}
}

View File

@ -18,207 +18,11 @@ namespace Phantom.Server.Database.Postgres.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.1")
.HasAnnotation("ProductVersion", "7.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("Roles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("RoleClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.AgentEntity", b =>
{
b.Property<Guid>("AgentGuid")
@ -270,15 +74,15 @@ namespace Phantom.Server.Database.Postgres.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid?>("UserGuid")
.HasColumnType("uuid");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("UserGuid");
b.ToTable("AuditLog", "system");
});
@ -370,92 +174,94 @@ namespace Phantom.Server.Database.Postgres.Migrations
b.ToTable("Permissions", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.RoleEntity", b =>
{
b.Property<Guid>("RoleGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("RoleGuid");
b.ToTable("Roles", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.RolePermissionEntity", b =>
{
b.Property<string>("RoleId")
.HasColumnType("text");
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("RoleId", "PermissionId");
b.HasKey("RoleGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("RolePermissions", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.UserEntity", b =>
{
b.Property<Guid>("UserGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("UserGuid");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.UserPermissionEntity", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("UserId", "PermissionId");
b.HasKey("UserGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("UserPermissions", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
modelBuilder.Entity("Phantom.Server.Database.Entities.UserRoleEntity", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
b.HasKey("UserGuid", "RoleGuid");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasIndex("RoleGuid");
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.ToTable("UserRoles", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.AuditLogEntity", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", "User")
b.HasOne("Phantom.Server.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId");
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("User");
});
@ -468,9 +274,9 @@ namespace Phantom.Server.Database.Postgres.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
b.HasOne("Phantom.Server.Database.Entities.RoleEntity", null)
.WithMany()
.HasForeignKey("RoleId")
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
@ -483,12 +289,31 @@ namespace Phantom.Server.Database.Postgres.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
b.HasOne("Phantom.Server.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserId")
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Server.Database.Entities.UserRoleEntity", b =>
{
b.HasOne("Phantom.Server.Database.Entities.RoleEntity", "Role")
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Server.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}

View File

@ -1,6 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Phantom.Common.Data;
@ -13,8 +11,12 @@ using Phantom.Server.Database.Factories;
namespace Phantom.Server.Database;
[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")]
public class ApplicationDbContext : IdentityDbContext {
public class ApplicationDbContext : DbContext {
public DbSet<UserEntity> Users { get; set; } = null!;
public DbSet<RoleEntity> Roles { get; set; } = null!;
public DbSet<PermissionEntity> Permissions { get; set; } = null!;
public DbSet<UserRoleEntity> UserRoles { get; set; } = null!;
public DbSet<UserPermissionEntity> UserPermissions { get; set; } = null!;
public DbSet<RolePermissionEntity> RolePermissions { get; set; } = null!;
@ -34,26 +36,29 @@ public class ApplicationDbContext : IdentityDbContext {
protected override void OnModelCreating(ModelBuilder builder) {
base.OnModelCreating(builder);
const string IdentitySchema = "identity";
builder.Entity<AuditLogEntity>(static b => {
b.HasOne(static e => e.User).WithMany().HasForeignKey(static e => e.UserGuid).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<IdentityRole>().ToTable("Roles", schema: IdentitySchema);
builder.Entity<IdentityRoleClaim<string>>().ToTable("RoleClaims", schema: IdentitySchema);
builder.Entity<UserEntity>(static b => {
b.HasIndex(static e => e.Name).IsUnique();
});
builder.Entity<IdentityUser>().ToTable("Users", schema: IdentitySchema);
builder.Entity<IdentityUserRole<string>>().ToTable("UserRoles", schema: IdentitySchema);
builder.Entity<IdentityUserLogin<string>>().ToTable("UserLogins", schema: IdentitySchema);
builder.Entity<IdentityUserToken<string>>().ToTable("UserTokens", schema: IdentitySchema);
builder.Entity<IdentityUserClaim<string>>().ToTable("UserClaims", schema: IdentitySchema);
builder.Entity<UserRoleEntity>(static b => {
b.HasKey(static e => new { UserId = e.UserGuid, RoleId = e.RoleGuid });
b.HasOne(static e => e.User).WithMany().HasForeignKey(static e => e.UserGuid).IsRequired().OnDelete(DeleteBehavior.Cascade);
b.HasOne(static e => e.Role).WithMany().HasForeignKey(static e => e.RoleGuid).IsRequired().OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<UserPermissionEntity>(static b => {
b.HasKey(static e => new { e.UserId, e.PermissionId });
b.HasOne<IdentityUser>().WithMany().HasForeignKey(static e => e.UserId).IsRequired().OnDelete(DeleteBehavior.Cascade);
b.HasKey(static e => new { UserId = e.UserGuid, e.PermissionId });
b.HasOne<UserEntity>().WithMany().HasForeignKey(static e => e.UserGuid).IsRequired().OnDelete(DeleteBehavior.Cascade);
b.HasOne<PermissionEntity>().WithMany().HasForeignKey(static e => e.PermissionId).IsRequired().OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<RolePermissionEntity>(static b => {
b.HasKey(static e => new { e.RoleId, e.PermissionId });
b.HasOne<IdentityRole>().WithMany().HasForeignKey(static e => e.RoleId).IsRequired().OnDelete(DeleteBehavior.Cascade);
b.HasKey(static e => new { RoleId = e.RoleGuid, e.PermissionId });
b.HasOne<RoleEntity>().WithMany().HasForeignKey(static e => e.RoleGuid).IsRequired().OnDelete(DeleteBehavior.Cascade);
b.HasOne<PermissionEntity>().WithMany().HasForeignKey(static e => e.PermissionId).IsRequired().OnDelete(DeleteBehavior.Cascade);
});
}

View File

@ -2,7 +2,6 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using Phantom.Server.Database.Enums;
namespace Phantom.Server.Database.Entities;
@ -16,22 +15,22 @@ public class AuditLogEntity : IDisposable {
[SuppressMessage("ReSharper", "UnusedMember.Global")]
public long Id { get; set; }
public string? UserId { get; set; }
public Guid? UserGuid { get; set; }
public DateTime UtcTime { get; set; } // Note: Converting to UTC is not best practice, but for historical records it's good enough.
public AuditLogEventType EventType { get; set; }
public AuditLogSubjectType SubjectType { get; set; }
public string SubjectId { get; set; }
public JsonDocument? Data { get; set; }
public virtual IdentityUser? User { get; set; }
public virtual UserEntity? User { get; set; }
[SuppressMessage("ReSharper", "UnusedMember.Global")]
internal AuditLogEntity() {
SubjectId = string.Empty;
}
public AuditLogEntity(string? userId, AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? data) {
UserId = userId;
public AuditLogEntity(Guid? userGuid, AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? data) {
UserGuid = userGuid;
UtcTime = DateTime.UtcNow;
EventType = eventType;
SubjectType = eventType.GetSubjectType();

View File

@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Phantom.Server.Database.Entities;
[Table("Roles", Schema = "identity")]
public sealed class RoleEntity {
[Key]
public Guid RoleGuid { get; set; }
public string Name { get; set; }
public RoleEntity(Guid roleGuid, string name) {
RoleGuid = roleGuid;
Name = name;
}
}

View File

@ -4,11 +4,11 @@ namespace Phantom.Server.Database.Entities;
[Table("RolePermissions", Schema = "identity")]
public sealed class RolePermissionEntity {
public string RoleId { get; set; }
public Guid RoleGuid { get; set; }
public string PermissionId { get; set; }
public RolePermissionEntity(string roleId, string permissionId) {
RoleId = roleId;
public RolePermissionEntity(Guid roleGuid, string permissionId) {
RoleGuid = roleGuid;
PermissionId = permissionId;
}
}

View File

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Phantom.Server.Database.Entities;
[Table("Users", Schema = "identity")]
public sealed class UserEntity {
[Key]
public Guid UserGuid { get; set; }
public string Name { get; set; }
public string PasswordHash { get; set; }
public UserEntity(Guid userGuid, string name) {
UserGuid = userGuid;
Name = name;
PasswordHash = null!;
}
}

View File

@ -4,11 +4,11 @@ namespace Phantom.Server.Database.Entities;
[Table("UserPermissions", Schema = "identity")]
public sealed class UserPermissionEntity {
public string UserId { get; set; }
public Guid UserGuid { get; set; }
public string PermissionId { get; set; }
public UserPermissionEntity(string userId, string permissionId) {
UserId = userId;
public UserPermissionEntity(Guid userGuid, string permissionId) {
UserGuid = userGuid;
PermissionId = permissionId;
}
}

View File

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Phantom.Server.Database.Entities;
[Table("UserRoles", Schema = "identity")]
public sealed class UserRoleEntity {
public Guid UserGuid { get; set; }
public Guid RoleGuid { get; set; }
public UserEntity User { get; set; }
public RoleEntity Role { get; set; }
public UserRoleEntity(Guid userGuid, Guid roleGuid) {
UserGuid = userGuid;
RoleGuid = roleGuid;
User = null!;
Role = null!;
}
}

View File

@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -1,30 +1,30 @@
using Microsoft.AspNetCore.Identity;
using Phantom.Server.Database.Entities;
using Phantom.Server.Database.Enums;
namespace Phantom.Server.Services.Audit;
public sealed partial class AuditLog {
public Task AddAdministratorUserCreatedEvent(IdentityUser administratorUser) {
return AddItem(AuditLogEventType.AdministratorUserCreated, administratorUser.Id);
public Task AddAdministratorUserCreatedEvent(UserEntity administratorUser) {
return AddItem(AuditLogEventType.AdministratorUserCreated, administratorUser.UserGuid.ToString());
}
public Task AddAdministratorUserModifiedEvent(IdentityUser administratorUser) {
return AddItem(AuditLogEventType.AdministratorUserModified, administratorUser.Id);
public Task AddAdministratorUserModifiedEvent(UserEntity administratorUser) {
return AddItem(AuditLogEventType.AdministratorUserModified, administratorUser.UserGuid.ToString());
}
public void AddUserLoggedInEvent(string userId) {
AddItem(userId, AuditLogEventType.UserLoggedIn, userId);
public void AddUserLoggedInEvent(UserEntity user) {
AddItem(user.UserGuid, AuditLogEventType.UserLoggedIn, user.UserGuid.ToString());
}
public void AddUserLoggedOutEvent(string userId) {
AddItem(userId, AuditLogEventType.UserLoggedOut, userId);
public void AddUserLoggedOutEvent(Guid userGuid) {
AddItem(userGuid, AuditLogEventType.UserLoggedOut, userGuid.ToString());
}
public Task AddUserCreatedEvent(IdentityUser user) {
return AddItem(AuditLogEventType.UserCreated, user.Id);
public Task AddUserCreatedEvent(UserEntity user) {
return AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString());
}
public Task AddUserRolesChangedEvent(IdentityUser user, List<string> addedToRoles, List<string> removedFromRoles) {
public Task AddUserRolesChangedEvent(UserEntity user, List<string> addedToRoles, List<string> removedFromRoles) {
var extra = new Dictionary<string, object?>();
if (addedToRoles.Count > 0) {
@ -35,12 +35,12 @@ public sealed partial class AuditLog {
extra["removedFromRoles"] = removedFromRoles;
}
return AddItem(AuditLogEventType.UserRolesChanged, user.Id, extra);
return AddItem(AuditLogEventType.UserRolesChanged, user.UserGuid.ToString(), extra);
}
public Task AddUserDeletedEvent(IdentityUser user) {
return AddItem(AuditLogEventType.UserDeleted, user.Id, new Dictionary<string, object?> {
{ "username", user.UserName }
public Task AddUserDeletedEvent(UserEntity user) {
return AddItem(AuditLogEventType.UserDeleted, user.UserGuid.ToString(), new Dictionary<string, object?> {
{ "username", user.Name }
});
}

View File

@ -11,21 +11,19 @@ namespace Phantom.Server.Services.Audit;
public sealed partial class AuditLog {
private readonly CancellationToken cancellationToken;
private readonly DatabaseProvider databaseProvider;
private readonly IdentityLookup identityLookup;
private readonly AuthenticationStateProvider authenticationStateProvider;
private readonly TaskManager taskManager;
public AuditLog(ServiceConfiguration serviceConfiguration, DatabaseProvider databaseProvider, IdentityLookup identityLookup, AuthenticationStateProvider authenticationStateProvider, TaskManager taskManager) {
public AuditLog(ServiceConfiguration serviceConfiguration, DatabaseProvider databaseProvider, AuthenticationStateProvider authenticationStateProvider, TaskManager taskManager) {
this.cancellationToken = serviceConfiguration.CancellationToken;
this.databaseProvider = databaseProvider;
this.identityLookup = identityLookup;
this.authenticationStateProvider = authenticationStateProvider;
this.taskManager = taskManager;
}
private async Task<string?> GetCurrentAuthenticatedUserId() {
private async Task<Guid?> GetCurrentAuthenticatedUserId() {
var authenticationState = await authenticationStateProvider.GetAuthenticationStateAsync();
return identityLookup.GetAuthenticatedUserId(authenticationState.User);
return UserManager.GetAuthenticatedUserId(authenticationState.User);
}
private async Task AddEntityToDatabase(AuditLogEntity logEntity) {
@ -34,8 +32,8 @@ public sealed partial class AuditLog {
await scope.Ctx.SaveChangesAsync(cancellationToken);
}
private void AddItem(string? userId, AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
var logEntity = new AuditLogEntity(userId, eventType, subjectId, extra);
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));
}
@ -50,7 +48,7 @@ public sealed partial class AuditLog {
.AsQueryable()
.OrderByDescending(static entity => entity.UtcTime)
.Take(count)
.Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserId, entity.User == null ? null : entity.User.UserName, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
.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);
}
}

View File

@ -3,4 +3,4 @@ using Phantom.Server.Database.Enums;
namespace Phantom.Server.Services.Audit;
public sealed record AuditLogItem(DateTime UtcTime, string? UserId, 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);

View File

@ -0,0 +1,8 @@
namespace Phantom.Server.Services.Users;
public enum AddRoleError : byte {
NameIsEmpty,
NameIsTooLong,
NameAlreadyExists,
UnknownError
}

View File

@ -0,0 +1,29 @@
using System.Collections.Immutable;
namespace Phantom.Server.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;
}
public static class AddUserErrorExtensions {
public static string ToSentences(this AddUserError error, string delimiter) {
return error switch {
AddUserError.NameIsEmpty => "Name cannot be empty.",
AddUserError.NameIsTooLong e => "Name cannot be longer than " + e.MaximumLength + " character(s).",
AddUserError.NameAlreadyExists => "Name is already occupied.",
AddUserError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
_ => "Unknown error."
};
}
}

View File

@ -0,0 +1,7 @@
namespace Phantom.Server.Services.Users;
public enum DeleteUserResult : byte {
Deleted,
NotFound,
Failed
}

View File

@ -1,16 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
namespace Phantom.Server.Services.Users;
public sealed class IdentityLookup {
private readonly UserManager<IdentityUser> userManager;
public IdentityLookup(UserManager<IdentityUser> userManager) {
this.userManager = userManager;
}
public string? GetAuthenticatedUserId(ClaimsPrincipal user) {
return user.Identity is { IsAuthenticated: true } ? userManager.GetUserId(user) : null;
}
}

View File

@ -0,0 +1,25 @@
namespace Phantom.Server.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;
}
public static class PasswordRequirementViolationExtensions {
public static string ToSentence(this PasswordRequirementViolation violation) {
return violation switch {
PasswordRequirementViolation.TooShort v => "Password must be at least " + v.MinimumLength + " character(s) long.",
PasswordRequirementViolation.LowercaseLetterRequired => "Password must contain a lowercase letter.",
PasswordRequirementViolation.UppercaseLetterRequired => "Password must contain an uppercase letter.",
PasswordRequirementViolation.DigitRequired => "Password must contain a digit.",
_ => "Unknown error."
};
}
}

View File

@ -0,0 +1,60 @@
using System.Collections.Immutable;
using Microsoft.EntityFrameworkCore;
using Phantom.Common.Logging;
using Phantom.Server.Database;
using Phantom.Server.Database.Entities;
using Phantom.Utils.Collections;
using Phantom.Utils.Tasks;
using ILogger = Serilog.ILogger;
namespace Phantom.Server.Services.Users;
public sealed class RoleManager {
private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>();
private const int MaxRoleNameLength = 40;
private readonly ApplicationDbContext db;
public RoleManager(ApplicationDbContext db) {
this.db = db;
}
public Task<List<RoleEntity>> GetAll() {
return db.Roles.ToListAsync();
}
public Task<ImmutableHashSet<string>> GetAllNames() {
return db.Roles.Select(static role => role.Name).AsAsyncEnumerable().ToImmutableSetAsync();
}
public ValueTask<RoleEntity?> GetByGuid(Guid guid) {
return db.Roles.FindAsync(guid);
}
public async Task<Result<RoleEntity, AddRoleError>> Create(Guid guid, 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);
}
try {
if (await db.Roles.AnyAsync(role => role.Name == name)) {
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameAlreadyExists);
}
var role = new RoleEntity(guid, name);
db.Roles.Add(role);
await db.SaveChangesAsync();
Logger.Information("Created role \"{Name}\" (GUID {Guid}).", name, guid);
return Result.Ok<RoleEntity, AddRoleError>(role);
} catch (Exception e) {
Logger.Error(e, "Could not create role \"{Name}\" (GUID {Guid}).", name, guid);
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.UnknownError);
}
}
}

View File

@ -0,0 +1,23 @@
using System.Collections.Immutable;
namespace Phantom.Server.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;
}
public static class SetUserPasswordErrorExtensions {
public static string ToSentences(this SetUserPasswordError error, string delimiter) {
return error switch {
SetUserPasswordError.UserNotFound => "User not found.",
SetUserPasswordError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())),
_ => "Unknown error."
};
}
}

View File

@ -0,0 +1,148 @@
using System.Collections.Immutable;
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Phantom.Common.Logging;
using Phantom.Server.Database;
using Phantom.Server.Database.Entities;
using Phantom.Utils.Collections;
using Phantom.Utils.Tasks;
using ILogger = Serilog.ILogger;
namespace Phantom.Server.Services.Users;
public sealed class UserManager {
private static readonly ILogger Logger = PhantomLogger.Create<UserManager>();
private const int MaxUserNameLength = 40;
private readonly ApplicationDbContext db;
public UserManager(ApplicationDbContext db) {
this.db = db;
}
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 Task<ImmutableArray<UserEntity>> GetAll() {
return db.Users.AsAsyncEnumerable().ToImmutableArrayAsync();
}
public Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) {
return db.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken);
}
public Task<UserEntity?> GetByName(string username) {
return db.Users.FirstOrDefaultAsync(user => user.Name == username);
}
public async Task<UserEntity?> GetAuthenticated(string username, string password) {
var user = await db.Users.FirstOrDefaultAsync(user => user.Name == username);
if (user == null) {
return null;
}
switch (UserPasswords.Verify(user, password)) {
case PasswordVerificationResult.SuccessRehashNeeded:
try {
UserPasswords.Set(user, password);
await db.SaveChangesAsync();
} catch (Exception e) {
Logger.Warning(e, "Could not rehash password for \"{Username}\".", user.Name);
}
goto case PasswordVerificationResult.Success;
case PasswordVerificationResult.Success:
return user;
case PasswordVerificationResult.Failed:
return null;
}
throw new InvalidOperationException();
}
public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) {
if (string.IsNullOrWhiteSpace(username)) {
return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameIsEmpty());
}
else if (username.Length > MaxUserNameLength) {
return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameIsTooLong(MaxUserNameLength));
}
var requirementViolations = UserPasswords.CheckRequirements(password);
if (!requirementViolations.IsEmpty) {
return Result.Fail<UserEntity, AddUserError>(new AddUserError.PasswordIsInvalid(requirementViolations));
}
try {
if (await db.Users.AnyAsync(user => user.Name == username)) {
return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameAlreadyExists());
}
var guid = Guid.NewGuid();
var user = new UserEntity(guid, username);
UserPasswords.Set(user, password);
db.Users.Add(user);
await db.SaveChangesAsync();
Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, guid);
return Result.Ok<UserEntity, AddUserError>(user);
} catch (Exception e) {
Logger.Error(e, "Could not create user \"{Name}\".", username);
return Result.Fail<UserEntity, AddUserError>(new AddUserError.UnknownError());
}
}
public async Task<Result<SetUserPasswordError>> SetUserPassword(Guid guid, string password) {
var user = await db.Users.FindAsync(guid);
if (user == null) {
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound());
}
try {
var requirementViolations = UserPasswords.CheckRequirements(password);
if (!requirementViolations.IsEmpty) {
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations));
}
UserPasswords.Set(user, password);
await db.SaveChangesAsync();
Logger.Information("Changed password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
return Result.Ok<SetUserPasswordError>();
} 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());
}
}
public async Task<DeleteUserResult> DeleteByGuid(Guid guid) {
var user = await db.Users.FindAsync(guid);
if (user == null) {
return DeleteUserResult.NotFound;
}
try {
db.Users.Remove(user);
await db.SaveChangesAsync();
return DeleteUserResult.Deleted;
} catch (Exception e) {
Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
return DeleteUserResult.Failed;
}
}
}

View File

@ -0,0 +1,41 @@
using System.Collections.Immutable;
using Microsoft.AspNetCore.Identity;
using Phantom.Server.Database.Entities;
namespace Phantom.Server.Services.Users;
internal static class UserPasswords {
private static PasswordHasher<UserEntity> Hasher { get; } = new ();
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 = Hasher.HashPassword(user, password);
}
public static PasswordVerificationResult Verify(UserEntity user, string password) {
return Hasher.VerifyHashedPassword(user, user.PasswordHash, password);
}
}

View File

@ -0,0 +1,76 @@
using System.Collections.Immutable;
using Microsoft.EntityFrameworkCore;
using Phantom.Common.Logging;
using Phantom.Server.Database;
using Phantom.Server.Database.Entities;
using Phantom.Utils.Collections;
using ILogger = Serilog.ILogger;
namespace Phantom.Server.Services.Users;
public sealed class UserRoleManager {
private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>();
private readonly ApplicationDbContext db;
public UserRoleManager(ApplicationDbContext db) {
this.db = db;
}
public Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() {
return db.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.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.UserRoles
.Where(ur => ur.UserGuid == user.UserGuid)
.Select(static ur => ur.RoleGuid)
.AsAsyncEnumerable()
.ToImmutableSetAsync();
}
public async Task<bool> Add(UserEntity user, RoleEntity role) {
try {
var userRole = await db.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
if (userRole == null) {
userRole = new UserRoleEntity(user.UserGuid, role.RoleGuid);
db.UserRoles.Add(userRole);
await db.SaveChangesAsync();
}
Logger.Information("Added user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
return true;
} 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;
}
}
public async Task<bool> Remove(UserEntity user, RoleEntity role) {
try {
var userRole = await db.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
if (userRole != null) {
db.UserRoles.Remove(userRole);
await db.SaveChangesAsync();
}
Logger.Information("Removed user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
return true;
} 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;
}
}
}

View File

@ -1,6 +1,8 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Phantom.Common.Logging;
using Phantom.Server.Services.Users;
using Phantom.Server.Web.Identity.Interfaces;
using Phantom.Utils.Cryptography;
using ILogger = Serilog.ILogger;
@ -13,67 +15,63 @@ public sealed class PhantomLoginManager {
public static bool IsAuthenticated(ClaimsPrincipal user) {
return user.Identity is { IsAuthenticated: true };
}
internal static string? GetAuthenticatedUserId(ClaimsPrincipal user, UserManager<IdentityUser> userManager) {
return IsAuthenticated(user) ? userManager.GetUserId(user) : null;
}
private readonly INavigation navigation;
private readonly UserManager userManager;
private readonly PhantomLoginStore loginStore;
private readonly UserManager<IdentityUser> userManager;
private readonly SignInManager<IdentityUser> signInManager;
private readonly ILoginEvents loginEvents;
public PhantomLoginManager(INavigation navigation, PhantomLoginStore loginStore, UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager, ILoginEvents loginEvents) {
public PhantomLoginManager(INavigation navigation, UserManager userManager, PhantomLoginStore loginStore, ILoginEvents loginEvents) {
this.navigation = navigation;
this.loginStore = loginStore;
this.userManager = userManager;
this.signInManager = signInManager;
this.loginStore = loginStore;
this.loginEvents = loginEvents;
}
public async Task<SignInResult> SignIn(string username, string password, string? returnUrl = null) {
var user = await userManager.FindByNameAsync(username);
if (user == null) {
return SignInResult.Failed;
public async Task<bool> SignIn(string username, string password, string? returnUrl = null) {
if (await userManager.GetAuthenticated(username, password) == null) {
return false;
}
var result = await signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true);
if (result == SignInResult.Success) {
Logger.Debug("Created login token for {Username}.", username);
string token = TokenGenerator.Create(60);
loginStore.Add(token, user, password, returnUrl ?? string.Empty);
navigation.NavigateTo("login" + QueryString.Create("token", token), forceLoad: true);
}
return result;
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 SignOut() {
if (GetAuthenticatedUserId(signInManager.Context.User, userManager) is {} userId) {
loginEvents.UserLoggedOut(userId);
}
await signInManager.SignOutAsync();
}
internal async Task<string?> ProcessTokenAndGetReturnUrl(string token) {
internal async Task<SignInResult?> ProcessToken(string token) {
var entry = loginStore.Pop(token);
if (entry == null) {
return null;
}
var user = entry.User;
var result = await signInManager.PasswordSignInAsync(user, entry.Password, lockoutOnFailure: false, isPersistent: true);
if (result == SignInResult.Success) {
Logger.Information("Successful login for {Username}.", user.UserName);
loginEvents.UserLoggedIn(user.Id);
return entry.ReturnUrl;
}
else {
Logger.Warning("Error logging in {Username}: {Result}.", user.UserName, result);
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);
}
}
}

View File

@ -1,6 +1,5 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Microsoft.AspNetCore.Identity;
using Phantom.Common.Logging;
using Phantom.Utils.Tasks;
using ILogger = Serilog.ILogger;
@ -30,7 +29,7 @@ public sealed class PhantomLoginStore {
foreach (var (token, entry) in loginEntries) {
if (entry.IsExpired) {
Logger.Debug("Expired login entry for {Username}.", entry.User.UserName);
Logger.Debug("Expired login entry for {Username}.", entry.Username);
loginEntries.TryRemove(token, out _);
}
}
@ -40,8 +39,8 @@ public sealed class PhantomLoginStore {
}
}
internal void Add(string token, IdentityUser user, string password, string returnUrl) {
loginEntries[token] = new LoginEntry(user, password, returnUrl, Stopwatch.StartNew());
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) {
@ -50,14 +49,14 @@ public sealed class PhantomLoginStore {
}
if (entry.IsExpired) {
Logger.Debug("Expired login entry for {Username}.", entry.User.UserName);
Logger.Debug("Expired login entry for {Username}.", entry.Username);
return null;
}
return entry;
}
internal sealed record LoginEntry(IdentityUser User, string Password, string ReturnUrl, Stopwatch AddedTime) {
internal sealed record LoginEntry(string Username, string Password, string ReturnUrl, Stopwatch AddedTime) {
public bool IsExpired => AddedTime.Elapsed >= ExpirationTime;
}
}

View File

@ -1,50 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Phantom.Server.Web.Identity.Authentication;
sealed class RevalidatingIdentityAuthenticationStateProvider<TUser> : RevalidatingServerAuthenticationStateProvider where TUser : class {
private readonly IServiceScopeFactory scopeFactory;
private readonly IdentityOptions options;
public RevalidatingIdentityAuthenticationStateProvider(ILoggerFactory loggerFactory, IServiceScopeFactory scopeFactory, IOptions<IdentityOptions> optionsAccessor) : base(loggerFactory) {
this.scopeFactory = scopeFactory;
this.options = optionsAccessor.Value;
}
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
protected override async Task<bool> ValidateAuthenticationStateAsync(AuthenticationState authenticationState, CancellationToken cancellationToken) {
// Get the user manager from a new scope to ensure it fetches fresh data
var scope = scopeFactory.CreateScope();
try {
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<TUser>>();
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
} finally {
if (scope is IAsyncDisposable asyncDisposable) {
await asyncDisposable.DisposeAsync();
}
else {
scope.Dispose();
}
}
}
private async Task<bool> ValidateSecurityStampAsync(UserManager<TUser> userManager, ClaimsPrincipal principal) {
var user = await userManager.GetUserAsync(principal);
if (user == null) {
return false;
}
else if (!userManager.SupportsUserSecurityStamp) {
return true;
}
else {
var principalStamp = principal.FindFirstValue(options.ClaimsIdentity.SecurityStampClaimType);
var userStamp = await userManager.GetSecurityStampAsync(user);
return principalStamp == userStamp;
}
}
}

View File

@ -1,29 +1,26 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Phantom.Server.Database;
using Phantom.Server.Web.Identity.Authentication;
using Phantom.Server.Services.Users;
using Phantom.Server.Web.Identity.Data;
namespace Phantom.Server.Web.Identity.Authorization;
public sealed class PermissionManager {
private readonly DatabaseProvider databaseProvider;
private readonly UserManager<IdentityUser> userManager;
private readonly Dictionary<string, IdentityPermissions> userIdsToPermissionIds = new ();
private readonly Dictionary<Guid, IdentityPermissions> userIdsToPermissionIds = new ();
public PermissionManager(DatabaseProvider databaseProvider, UserManager<IdentityUser> userManager) {
public PermissionManager(DatabaseProvider databaseProvider) {
this.databaseProvider = databaseProvider;
this.userManager = userManager;
}
private IdentityPermissions FetchPermissionsForUserId(string userId) {
private IdentityPermissions FetchPermissionsForUserId(Guid userId) {
using var scope = databaseProvider.CreateScope();
var userPermissions = scope.Ctx.UserPermissions.Where(up => up.UserId == userId).Select(static up => up.PermissionId);
var rolePermissions = scope.Ctx.UserRoles.Where(ur => ur.UserId == userId).Join(scope.Ctx.RolePermissions, static ur => ur.RoleId, static rp => rp.RoleId, static (ur, rp) => rp.PermissionId);
var userPermissions = scope.Ctx.UserPermissions.Where(up => up.UserGuid == userId).Select(static up => up.PermissionId);
var rolePermissions = scope.Ctx.UserRoles.Where(ur => ur.UserGuid == userId).Join(scope.Ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
return new IdentityPermissions(userPermissions.Union(rolePermissions));
}
private IdentityPermissions GetPermissionsForUserId(string userId, bool refreshCache) {
private IdentityPermissions GetPermissionsForUserId(Guid userId, bool refreshCache) {
if (!refreshCache && userIdsToPermissionIds.TryGetValue(userId, out var userPermissions)) {
return userPermissions;
}
@ -33,8 +30,8 @@ public sealed class PermissionManager {
}
public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) {
string? userId = PhantomLoginManager.GetAuthenticatedUserId(user, userManager);
return userId == null ? IdentityPermissions.None : GetPermissionsForUserId(userId, refreshCache);
Guid? userId = UserManager.GetAuthenticatedUserId(user);
return userId == null ? IdentityPermissions.None : GetPermissionsForUserId(userId.Value, refreshCache);
}
public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) {

View File

@ -2,16 +2,20 @@
namespace Phantom.Server.Web.Identity.Data;
public sealed record Role(string Name, ImmutableArray<Permission> Permissions) {
public sealed record Role(Guid Guid, string Name, ImmutableArray<Permission> Permissions) {
private static readonly List<Role> AllRoles = new ();
internal static IEnumerable<Role> All => AllRoles;
private static Role Register(string name, ImmutableArray<Permission> permissions) {
var role = new Role(name, permissions);
private static Role Register(Guid guid, string name, ImmutableArray<Permission> permissions) {
var role = new Role(guid, name, permissions);
AllRoles.Add(role);
return role;
}
public static readonly Role Administrator = Register("Administrator", Permission.All.ToImmutableArray());
public static readonly Role InstanceManager = Register("Instance Manager", ImmutableArray.Create(Permission.ViewInstances, Permission.ViewInstanceLogs, Permission.CreateInstances, Permission.ControlInstances, Permission.ViewEvents));
private static Guid SystemRoleGuid(byte id) {
return new Guid(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, id);
}
public static readonly Role Administrator = Register(SystemRoleGuid(1), "Administrator", Permission.All.ToImmutableArray());
public static readonly Role InstanceManager = Register(SystemRoleGuid(2), "Instance Manager", ImmutableArray.Create(Permission.ViewInstances, Permission.ViewInstanceLogs, Permission.CreateInstances, Permission.ControlInstances, Permission.ViewEvents));
}

View File

@ -1,6 +1,8 @@
namespace Phantom.Server.Web.Identity.Interfaces;
using Phantom.Server.Database.Entities;
namespace Phantom.Server.Web.Identity.Interfaces;
public interface ILoginEvents {
void UserLoggedIn(string userId);
void UserLoggedOut(string userId);
void UserLoggedIn(UserEntity user);
void UserLoggedOut(Guid userGuid);
}

View File

@ -22,6 +22,7 @@
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
<ProjectReference Include="..\Phantom.Server.Database\Phantom.Server.Database.csproj" />
<ProjectReference Include="..\Phantom.Server.Services\Phantom.Server.Services.csproj" />
</ItemGroup>
</Project>

View File

@ -1,10 +1,13 @@
using System.Collections.Immutable;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Phantom.Common.Logging;
using Phantom.Server.Database;
using Phantom.Server.Database.Entities;
using Phantom.Server.Services.Users;
using Phantom.Server.Web.Identity.Data;
using Phantom.Utils.Collections;
using Phantom.Utils.Runtime;
using Phantom.Utils.Tasks;
using ILogger = Serilog.ILogger;
namespace Phantom.Server.Web.Identity;
@ -18,22 +21,22 @@ public sealed class PhantomIdentityConfigurator {
}
private readonly ApplicationDbContext db;
private readonly RoleManager<IdentityRole> roleManager;
private readonly RoleManager roleManager;
public PhantomIdentityConfigurator(ApplicationDbContext db, RoleManager<IdentityRole> roleManager) {
public PhantomIdentityConfigurator(ApplicationDbContext db, RoleManager roleManager) {
this.db = db;
this.roleManager = roleManager;
}
private async Task Initialize() {
CreatePermissions();
await CreatePermissions();
await CreateDefaultRoles();
await AssignDefaultRolePermissions();
await db.SaveChangesAsync();
}
private void CreatePermissions() {
var existingPermissionIds = db.Permissions.Select(static p => p.Id).ToHashSet();
private async Task CreatePermissions() {
var existingPermissionIds = await db.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync();
var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds);
if (!missingPermissionIds.IsEmpty) {
@ -45,55 +48,65 @@ public sealed class PhantomIdentityConfigurator {
}
private async Task CreateDefaultRoles() {
foreach (var role in Role.All) {
string name = role.Name;
if (await roleManager.RoleExistsAsync(name)) {
Logger.Information("Creating default roles.");
var allRoleNames = await roleManager.GetAllNames();
foreach (var (guid, name, _) in Role.All) {
if (allRoleNames.Contains(name)) {
continue;
}
Logger.Information("Creating default role {RoleName}.", name);
var result = await roleManager.CreateAsync(new IdentityRole(name));
if (!result.Succeeded) {
bool anyError = false;
foreach (var error in result.Errors) {
Logger.Fatal("Error creating default role {RoleName}: {Error}", name, error.Description);
anyError = true;
var result = await roleManager.Create(guid, name);
if (result is Result<RoleEntity, AddRoleError>.Fail fail) {
switch (fail.Error) {
case AddRoleError.NameIsEmpty:
Logger.Fatal("Error creating default role \"{Name}\", name is empty!", name);
throw StopProcedureException.Instance;
case AddRoleError.NameIsTooLong:
Logger.Fatal("Error creating default role \"{Name}\", name is too long!", name);
throw StopProcedureException.Instance;
case AddRoleError.NameAlreadyExists:
Logger.Warning("Error creating default role \"{Name}\", a role with this name already exists!", name);
throw StopProcedureException.Instance;
default:
Logger.Fatal("Error creating default role \"{Name}\", unknown error!", name);
throw StopProcedureException.Instance;
}
if (!anyError) {
Logger.Fatal("Error creating default role {RoleName} due to unknown error.", name);
}
throw StopProcedureException.Instance;
}
}
}
private async Task AssignDefaultRolePermissions() {
Logger.Information("Assigning default role permissions..");
Logger.Information("Assigning default role permissions.");
foreach (var role in Role.All) {
var roleEntity = await roleManager.FindByNameAsync(role.Name);
var roleEntity = await roleManager.GetByGuid(role.Guid);
if (roleEntity == null) {
Logger.Fatal("Error assigning default role permissions, role {RoleName} not found.", role.Name);
Logger.Fatal("Error assigning default role permissions, role \"{Name}\" with GUID {Guid} not found.", role.Name, role.Guid);
throw StopProcedureException.Instance;
}
var existingPermissionIds = db.RolePermissions.Where(rp => rp.RoleId == roleEntity.Id).Select(static rp => rp.PermissionId).ToHashSet();
var existingPermissionIds = await db.RolePermissions
.Where(rp => rp.RoleGuid == roleEntity.RoleGuid)
.Select(static rp => rp.PermissionId)
.AsAsyncEnumerable()
.ToImmutableSetAsync();
var missingPermissionIds = GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds);
if (!missingPermissionIds.IsEmpty) {
Logger.Information("Assigning default permission to role {RoleName}: {Permissions}", role.Name, string.Join(", ", missingPermissionIds));
Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds));
foreach (var permissionId in missingPermissionIds) {
db.RolePermissions.Add(new RolePermissionEntity(roleEntity.Id, permissionId));
db.RolePermissions.Add(new RolePermissionEntity(roleEntity.RoleGuid, permissionId));
}
}
}
}
private static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, HashSet<string> existingPermissionIds) {
private static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
}
}

View File

@ -1,8 +1,8 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Phantom.Server.Database;
using Microsoft.AspNetCore.Components.Server;
using Phantom.Server.Services.Users;
using Phantom.Server.Web.Identity.Authentication;
using Phantom.Server.Web.Identity.Authorization;
using Phantom.Server.Web.Identity.Data;
@ -10,10 +10,8 @@ using Phantom.Server.Web.Identity.Data;
namespace Phantom.Server.Web.Identity;
public static class PhantomIdentityExtensions {
public static void AddPhantomIdentity<TUser, TRole>(this IServiceCollection services, CancellationToken cancellationToken) where TUser : class where TRole : class {
services.AddIdentity<TUser, TRole>(ConfigureIdentity).AddEntityFrameworkStores<ApplicationDbContext>();
services.ConfigureApplicationCookie(ConfigureIdentityCookie);
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
public static void AddPhantomIdentity(this IServiceCollection services, CancellationToken cancellationToken) {
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(ConfigureIdentityCookie);
services.AddAuthorization(ConfigureAuthorization);
services.AddSingleton(PhantomLoginStore.Create(cancellationToken));
@ -21,8 +19,11 @@ public static class PhantomIdentityExtensions {
services.AddScoped<PhantomIdentityConfigurator>();
services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>();
services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<TUser>>();
services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
services.AddScoped<UserManager>();
services.AddScoped<RoleManager>();
services.AddScoped<UserRoleManager>();
services.AddTransient<PermissionManager>();
}
@ -32,23 +33,6 @@ public static class PhantomIdentityExtensions {
application.UseWhen(PhantomIdentityMiddleware.AcceptsPath, static app => app.UseMiddleware<PhantomIdentityMiddleware>());
}
private static void ConfigureIdentity(IdentityOptions o) {
o.SignIn.RequireConfirmedAccount = false;
o.SignIn.RequireConfirmedEmail = false;
o.SignIn.RequireConfirmedPhoneNumber = false;
o.Password.RequireLowercase = true;
o.Password.RequireUppercase = true;
o.Password.RequireDigit = true;
o.Password.RequiredLength = 16;
o.Lockout.AllowedForNewUsers = true;
o.Lockout.MaxFailedAccessAttempts = 10;
o.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(1);
o.Stores.MaxLengthForKeys = 128;
}
private static void ConfigureIdentityCookie(CookieAuthenticationOptions o) {
o.Cookie.Name = "Phantom.Identity";
o.Cookie.HttpOnly = true;

View File

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Authentication;
using Phantom.Server.Web.Identity.Authentication;
using Phantom.Server.Web.Identity.Interfaces;
@ -22,11 +23,13 @@ sealed class PhantomIdentityMiddleware {
[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.ProcessTokenAndGetReturnUrl(token) is {} returnUrl) {
context.Response.Redirect(navigation.BasePath + returnUrl);
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) {
await loginManager.SignOut();
loginManager.OnSignedOut(context.User);
await context.SignOutAsync();
context.Response.Redirect(navigation.BasePath);
}
else {

View File

@ -1,4 +1,5 @@
using Phantom.Server.Services.Audit;
using Phantom.Server.Database.Entities;
using Phantom.Server.Services.Audit;
using Phantom.Server.Web.Identity.Interfaces;
namespace Phantom.Server.Web.Base;
@ -10,11 +11,11 @@ sealed class LoginEvents : ILoginEvents {
this.auditLog = auditLog;
}
public void UserLoggedIn(string userId) {
auditLog.AddUserLoggedInEvent(userId);
public void UserLoggedIn(UserEntity user) {
auditLog.AddUserLoggedInEvent(user);
}
public void UserLoggedOut(string userId) {
auditLog.AddUserLoggedOutEvent(userId);
public void UserLoggedOut(Guid userGuid) {
auditLog.AddUserLoggedOutEvent(userGuid);
}
}

View File

@ -1,5 +1,4 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Phantom.Server.Database;
using Phantom.Server.Web.Base;
@ -34,10 +33,10 @@ public static class Launcher {
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.KeyFolderPath));
builder.Services.AddDbContextPool<ApplicationDbContext>(dbOptionsBuilder, poolSize: 64);
builder.Services.AddDbContext<ApplicationDbContext>(dbOptionsBuilder, ServiceLifetime.Transient);
builder.Services.AddSingleton<DatabaseProvider>();
builder.Services.AddPhantomIdentity<IdentityUser, IdentityRole>(config.CancellationToken);
builder.Services.AddPhantomIdentity(config.CancellationToken);
builder.Services.AddScoped<ILoginEvents, LoginEvents>();
builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout");

View File

@ -1,15 +1,14 @@
@page "/audit"
@attribute [Authorize(Permission.ViewAuditPolicy)]
@using Phantom.Server.Database.Enums
@using Phantom.Server.Services.Audit
@using Phantom.Server.Services.Instances
@using Microsoft.EntityFrameworkCore
@using Microsoft.AspNetCore.Identity
@using Phantom.Server.Services.Users
@using System.Collections.Immutable
@using Phantom.Server.Database.Enums
@implements IDisposable
@inject AuditLog AuditLog
@inject InstanceManager InstanceManager
@inject UserManager<IdentityUser> UserManager
@inject UserManager UserManager
<h1>Audit Log</h1>
@ -33,7 +32,7 @@
<td>
@(logItem.UserName ?? "-")
<br>
<code class="text-uppercase">@logItem.UserId</code>
<code class="text-uppercase">@logItem.UserGuid</code>
</td>
<td>@logItem.EventType.ToNiceString()</td>
<td>
@ -55,7 +54,7 @@
private CancellationTokenSource? initializationCancellationTokenSource;
private AuditLogItem[] logItems = Array.Empty<AuditLogItem>();
private Dictionary<string, string>? userNamesById;
private Dictionary<Guid, string>? userNamesByGuid;
private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
protected override async Task OnInitializedAsync() {
@ -64,7 +63,7 @@
try {
logItems = await AuditLog.GetItems(50, cancellationToken);
userNamesById = await UserManager.Users.ToDictionaryAsync(static user => user.Id, static user => user.UserName ?? user.Id, cancellationToken);
userNamesByGuid = await UserManager.GetAllByGuid(static user => user.Name, cancellationToken);
instanceNamesByGuid = InstanceManager.GetInstanceNames();
} finally {
initializationCancellationTokenSource.Dispose();
@ -74,7 +73,7 @@
private string? GetSubjectName(AuditLogSubjectType type, string id) {
return type switch {
AuditLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
AuditLogSubjectType.User => userNamesById != null && userNamesById.TryGetValue(id, out var name) ? name : null,
AuditLogSubjectType.User => userNamesByGuid != null && userNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
_ => null
};
}

View File

@ -1,7 +1,6 @@
@page "/login"
@using Phantom.Server.Web.Identity.Interfaces
@using Phantom.Server.Web.Identity.Authentication
@using Microsoft.AspNetCore.Identity
@using System.ComponentModel.DataAnnotations
@attribute [AllowAnonymous]
@inject INavigation Navigation
@ -51,21 +50,9 @@
await form.SubmitModel.StartSubmitting();
string? returnUrl = Navigation.GetQueryParameter("return", out var url) ? url : null;
var result = await LoginManager.SignIn(form.Username, form.Password, returnUrl);
if (result != SignInResult.Success) {
form.SubmitModel.StopSubmitting(GetErrorMessage(result));
}
}
private static string GetErrorMessage(SignInResult result) {
if (result == SignInResult.Failed) {
return "Invalid username or password.";
}
else if (result == SignInResult.LockedOut) {
return "Too many failed login attempts. Please try again later.";
}
else {
return "Unknown error.";
if (!await LoginManager.SignIn(form.Username, form.Password, returnUrl)) {
form.SubmitModel.StopSubmitting("Invalid username or password.");
}
}

View File

@ -1,15 +1,19 @@
@page "/setup"
@using Phantom.Server.Services.Users
@using Phantom.Utils.Tasks
@using Phantom.Server.Database.Entities
@using Phantom.Server.Services
@using Phantom.Server.Services.Audit
@using Phantom.Server.Web.Identity.Authentication
@using Microsoft.AspNetCore.Identity
@using System.ComponentModel.DataAnnotations
@using Phantom.Utils.Cryptography
@using System.Security.Cryptography
@attribute [AllowAnonymous]
@inject ServiceConfiguration ServiceConfiguration
@inject PhantomLoginManager LoginManager
@inject UserManager<IdentityUser> UserManager
@inject UserManager UserManager
@inject RoleManager RoleManager
@inject UserRoleManager UserRoleManager
@inject AuditLog AuditLog
<h1>Administrator Setup</h1>
@ -62,15 +66,14 @@
form.SubmitModel.StopSubmitting("Invalid administrator token.");
return;
}
var createUserResult = await CreateOrUpdateAdministrator();
if (!createUserResult.Succeeded) {
form.SubmitModel.StopSubmitting(GetErrors(createUserResult));
if (await CreateOrUpdateAdministrator() is Result<string>.Fail fail) {
form.SubmitModel.StopSubmitting(fail.Error);
return;
}
var signInResult = await LoginManager.SignIn(form.Username, form.Password);
if (!signInResult.Succeeded) {
if (!signInResult) {
form.SubmitModel.StopSubmitting("Error logging in.");
}
}
@ -86,36 +89,46 @@
return CryptographicOperations.FixedTimeEquals(formTokenBytes, ServiceConfiguration.AdministratorToken);
}
private async Task<IdentityResult> CreateOrUpdateAdministrator() {
var existingUser = await UserManager.FindByNameAsync(form.Username);
private async Task<Result<string>> CreateOrUpdateAdministrator() {
var existingUser = await UserManager.GetByName(form.Username);
return existingUser == null ? await CreateAdministrator() : await UpdateAdministrator(existingUser);
}
private async Task<IdentityResult> CreateAdministrator() {
var newUser = new IdentityUser(form.Username);
var createUserResult = await UserManager.CreateAsync(newUser, form.Password);
if (!createUserResult.Succeeded) {
return createUserResult;
private async Task<Result<string>> CreateAdministrator() {
var administratorRole = await RoleManager.GetByGuid(Role.Administrator.Guid);
if (administratorRole == null) {
return Result.Fail("Administrator role not found.");
}
await AuditLog.AddAdministratorUserCreatedEvent(newUser);
var addToRoleResult = await UserManager.AddToRoleAsync(newUser, Role.Administrator.Name);
return addToRoleResult;
}
switch (await UserManager.CreateUser(form.Username, form.Password)) {
case Result<UserEntity, AddUserError>.Ok ok:
var administratorUser = ok.Value;
await AuditLog.AddAdministratorUserCreatedEvent(administratorUser);
private async Task<IdentityResult> UpdateAdministrator(IdentityUser existingUser) {
await UserManager.RemovePasswordAsync(existingUser);
var result = await UserManager.AddPasswordAsync(existingUser, form.Password);
if (result.Succeeded) {
await AuditLog.AddAdministratorUserModifiedEvent(existingUser);
if (!await UserRoleManager.Add(administratorUser, administratorRole)) {
return Result.Fail("Could not assign administrator role to user.");
}
return Result.Ok<string>();
case Result<UserEntity, AddUserError>.Fail fail:
return Result.Fail(fail.Error.ToSentences("\n"));
}
return result;
return Result.Fail("Unknown error.");
}
private string GetErrors(IdentityResult result) {
return string.Join("\n", result.Errors.Select(static error => error.Description));
private async Task<Result<string>> UpdateAdministrator(UserEntity existingUser) {
switch (await UserManager.SetUserPassword(existingUser.UserGuid, form.Password)) {
case Result<SetUserPasswordError>.Ok:
await AuditLog.AddAdministratorUserModifiedEvent(existingUser);
return Result.Ok<string>();
case Result<SetUserPasswordError>.Fail fail:
return Result.Fail(fail.Error.ToSentences("\n"));
}
return Result.Fail("Unknown error.");
}
}

View File

@ -1,11 +1,11 @@
@page "/users"
@using System.Collections.Immutable
@using Microsoft.AspNetCore.Identity
@using Phantom.Server.Database.Entities
@using Phantom.Server.Services.Users
@using System.Collections.Immutable
@attribute [Authorize(Permission.ViewUsersPolicy)]
@inject UserManager<IdentityUser> UserManager
@inject UserManager UserManager
@inject UserRoleManager UserRoleManager
@inject PermissionManager PermissionManager
@inject IdentityLookup IdentityLookup
<h1>Users</h1>
@ -28,20 +28,20 @@
</tr>
</thead>
<tbody>
@{ var myUserId = IdentityLookup.GetAuthenticatedUserId(context.User); }
@{ var myUserId = UserManager.GetAuthenticatedUserId(context.User); }
@foreach (var user in allUsers) {
var isMe = myUserId == user.Id;
var isMe = myUserId == user.UserGuid;
<tr>
<td>
<code class="text-uppercase">@user.Id</code>
<code class="text-uppercase">@user.UserGuid</code>
</td>
@if (isMe) {
<td class="fw-semibold">@user.UserName</td>
<td class="fw-semibold">@user.Name</td>
}
else {
<td>@user.UserName</td>
<td>@user.Name</td>
}
<td>@(userRoles.TryGetValue(user.Id, out var roles) ? roles : "-")</td>
<td>@(userGuidToRoleDescription.TryGetValue(user.UserGuid, out var roles) ? roles : "?")</td>
@if (canEdit) {
<td>
@if (!isMe) {
@ -65,42 +65,46 @@
@code {
private ImmutableArray<IdentityUser> allUsers = ImmutableArray<IdentityUser>.Empty;
private readonly Dictionary<string, string> userRoles = new();
private ImmutableArray<UserEntity> allUsers = ImmutableArray<UserEntity>.Empty;
private readonly Dictionary<Guid, string> userGuidToRoleDescription = new();
private UserRolesDialog userRolesDialog = null!;
private UserDeleteDialog userDeleteDialog = null!;
protected override void OnInitialized() {
allUsers = UserManager.Users.OrderBy(static user => user.UserName).ToImmutableArray();
}
protected override async Task OnInitializedAsync() {
var unsortedUsers = await UserManager.GetAll();
allUsers = unsortedUsers.Sort(static (a, b) => a.Name.CompareTo(b.Name));
foreach (var (userGuid, roles) in await UserRoleManager.GetAllByUserGuid()) {
userGuidToRoleDescription[userGuid] = StringifyRoles(roles);
}
foreach (var user in allUsers) {
await RefreshUserRoles(user);
}
}
private async Task RefreshUserRoles(IdentityUser user) {
var roles = await UserManager.GetRolesAsync(user);
if (roles.Count > 0) {
userRoles[user.Id] = string.Join(", ", roles);
}
else {
userRoles.Remove(user.Id);
}
private async Task RefreshUserRoles(UserEntity user) {
var roles = await UserRoleManager.GetUserRoles(user);
userGuidToRoleDescription[user.UserGuid] = StringifyRoles(roles);
}
private void OnUserAdded(IdentityUser user) {
private static string StringifyRoles(ImmutableArray<RoleEntity> roles) {
return roles.IsEmpty ? "-" : string.Join(", ", roles.Select(static role => role.Name));
}
private Task OnUserAdded(UserEntity user) {
allUsers = allUsers.Add(user);
return RefreshUserRoles(user);
}
private async Task OnUserRolesChanged(IdentityUser user) {
await RefreshUserRoles(user);
private Task OnUserRolesChanged(UserEntity user) {
return RefreshUserRoles(user);
}
private void OnUserDeleted(IdentityUser user) {
private void OnUserDeleted(UserEntity user) {
allUsers = allUsers.Remove(user);
userGuidToRoleDescription.Remove(user.UserGuid);
}
}

View File

@ -1,9 +1,11 @@
@using Microsoft.AspNetCore.Identity
@using Phantom.Server.Services.Users
@using Phantom.Server.Database.Entities
@using Phantom.Server.Services.Audit
@using System.ComponentModel.DataAnnotations
@using Phantom.Utils.Tasks
@inherits PhantomComponent
@inject IJSRuntime Js;
@inject UserManager<IdentityUser> UserManager
@inject UserManager UserManager
@inject AuditLog AuditLog
<Form Model="form" OnSubmit="AddUser">
@ -37,7 +39,7 @@
public string ModalId { get; set; } = string.Empty;
[Parameter]
public EventCallback<IdentityUser> UserAdded { get; set; }
public EventCallback<UserEntity> UserAdded { get; set; }
private readonly AddUserFormModel form = new();
@ -57,16 +59,17 @@
return;
}
var user = new IdentityUser(form.Username);
var result = await UserManager.CreateAsync(user, form.Password);
if (result.Succeeded) {
await AuditLog.AddUserCreatedEvent(user);
await UserAdded.InvokeAsync(user);
await Js.InvokeVoidAsync("closeModal", ModalId);
form.SubmitModel.StopSubmitting();
}
else {
form.SubmitModel.StopSubmitting(string.Join("\n", result.Errors.Select(static error => error.Description)));
switch (await UserManager.CreateUser(form.Username, form.Password)) {
case Result<UserEntity, AddUserError>.Ok ok:
await AuditLog.AddUserCreatedEvent(ok.Value);
await UserAdded.InvokeAsync(ok.Value);
await Js.InvokeVoidAsync("closeModal", ModalId);
form.SubmitModel.StopSubmitting();
break;
case Result<UserEntity, AddUserError>.Fail fail:
form.SubmitModel.StopSubmitting(fail.Error.ToSentences("\n"));
break;
}
}

View File

@ -1,7 +1,8 @@
@using Microsoft.AspNetCore.Identity
@using Phantom.Server.Database.Entities
@using Phantom.Server.Services.Audit
@using Phantom.Server.Services.Users
@inherits UserEditDialogBase
@inject UserManager<IdentityUser> UserManager
@inject UserManager UserManager
@inject AuditLog AuditLog
<Modal Id="@ModalId" TitleText="Delete User">
@ -18,14 +19,20 @@
@code {
protected override async Task DoEdit(IdentityUser user) {
var result = await UserManager.DeleteAsync(user);
if (result.Succeeded) {
await AuditLog.AddUserDeletedEvent(user);
await OnEditSuccess();
}
else {
OnEditFailure(string.Join("\n", result.Errors.Select(static error => error.Description)));
protected override async Task DoEdit(UserEntity user) {
switch (await UserManager.DeleteByGuid(user.UserGuid)) {
case DeleteUserResult.Deleted:
await AuditLog.AddUserDeletedEvent(user);
await OnEditSuccess();
break;
case DeleteUserResult.NotFound:
await OnEditSuccess();
break;
case DeleteUserResult.Failed:
OnEditFailure("Could not delete user.");
break;
}
}

View File

@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Identity;
using Microsoft.JSInterop;
using Phantom.Server.Database.Entities;
using Phantom.Server.Web.Base;
using Phantom.Server.Web.Components.Forms;
using Phantom.Server.Web.Identity.Data;
@ -15,23 +15,23 @@ public abstract class UserEditDialogBase : PhantomComponent {
public string ModalId { get; set; } = string.Empty;
[Parameter]
public EventCallback<IdentityUser> UserModified { get; set; }
public EventCallback<UserEntity> UserModified { get; set; }
protected readonly FormButtonSubmit.SubmitModel SubmitModel = new();
private IdentityUser? EditedUser { get; set; } = null;
private UserEntity? EditedUser { get; set; } = null;
protected string EditedUserName { get; private set; } = string.Empty;
internal async Task Show(IdentityUser user) {
internal async Task Show(UserEntity user) {
EditedUser = user;
EditedUserName = user.UserName ?? $"<{user.Id}>";
EditedUserName = user.Name;
await BeforeShown(user);
StateHasChanged();
await Js.InvokeVoidAsync("showModal", ModalId);
}
protected virtual Task BeforeShown(IdentityUser user) {
protected virtual Task BeforeShown(UserEntity user) {
return Task.CompletedTask;
}
@ -53,7 +53,7 @@ public abstract class UserEditDialogBase : PhantomComponent {
}
}
protected abstract Task DoEdit(IdentityUser user);
protected abstract Task DoEdit(UserEntity user);
protected async Task OnEditSuccess() {
await UserModified.InvokeAsync(EditedUser);

View File

@ -1,10 +1,10 @@
@using Phantom.Utils.Collections
@using System.Collections.Immutable
@using Microsoft.AspNetCore.Identity
@using Phantom.Server.Database.Entities
@using Phantom.Server.Services.Audit
@using Phantom.Server.Services.Users
@inherits UserEditDialogBase
@inject UserManager<IdentityUser> UserManager
@inject RoleManager<IdentityRole> RoleManager
@inject UserManager UserManager
@inject RoleManager RoleManager
@inject UserRoleManager UserRoleManager
@inject AuditLog AuditLog
<Modal Id="@ModalId" TitleText="Manage User Roles">
@ -14,7 +14,7 @@
var item = items[index];
<div class="mt-1">
<input id="role-@index" type="checkbox" class="form-check-input" @bind="@item.Checked" />
<label for="role-@index" class="form-check-label">@item.Name</label>
<label for="role-@index" class="form-check-label">@item.Role.Name</label>
</div>
}
</Body>
@ -29,37 +29,34 @@
private List<RoleItem> items = new();
protected override async Task BeforeShown(IdentityUser user) {
var userRoles = await GetUserRoles(user);
this.items = RoleManager.Roles
.Select(static role => role.Name)
.AsEnumerable()
.WhereNotNull()
.Select(role => new RoleItem(role, userRoles.Contains(role)))
.ToList();
protected override async Task BeforeShown(UserEntity user) {
var userRoles = await UserRoleManager.GetUserRoleGuids(user);
var allRoles = await RoleManager.GetAll();
this.items = allRoles.Select(role => new RoleItem(role, userRoles.Contains(role.RoleGuid))).ToList();
}
protected override async Task DoEdit(IdentityUser user) {
var userRoles = await GetUserRoles(user);
protected override async Task DoEdit(UserEntity user) {
var userRoles = await UserRoleManager.GetUserRoleGuids(user);
var addedToRoles = new List<string>();
var removedFromRoles = new List<string>();
var errors = new List<string>();
foreach (var item in items) {
var shouldHaveRole = item.Checked;
var roleName = item.Name;
if (shouldHaveRole == userRoles.Contains(roleName)) {
if (shouldHaveRole == userRoles.Contains(item.Role.RoleGuid)) {
continue;
}
var result = shouldHaveRole ? await UserManager.AddToRoleAsync(user, roleName) : await UserManager.RemoveFromRoleAsync(user, roleName);
if (result.Succeeded) {
var modifiedList = shouldHaveRole ? addedToRoles : removedFromRoles;
modifiedList.Add(roleName);
bool success = shouldHaveRole ? await UserRoleManager.Add(user, item.Role) : await UserRoleManager.Remove(user, item.Role);
if (success) {
var modifiedList = shouldHaveRole ? addedToRoles : removedFromRoles;
modifiedList.Add(item.Role.Name);
}
else if (shouldHaveRole) {
errors.Add("Could not add role " + item.Role.Name + " to user.");
}
else {
errors.AddRange(result.Errors.Select(static error => error.Description));
errors.Add("Could not remove role " + item.Role.Name + " from user.");
}
}
@ -72,16 +69,12 @@
}
}
private async Task<ImmutableHashSet<string>> GetUserRoles(IdentityUser user) {
return (await UserManager.GetRolesAsync(user)).ToImmutableHashSet();
}
private sealed class RoleItem {
public string Name { get; }
public RoleEntity Role { get; }
public bool Checked { get; set; }
public RoleItem(string name, bool @checked) {
this.Name = name;
public RoleItem(RoleEntity role, bool @checked) {
this.Role = role;
this.Checked = @checked;
}
}

View File

@ -7,7 +7,6 @@ using Phantom.Server.Services.Audit;
using Phantom.Server.Services.Events;
using Phantom.Server.Services.Instances;
using Phantom.Server.Services.Rpc;
using Phantom.Server.Services.Users;
using Phantom.Utils.Tasks;
using WebLauncher = Phantom.Server.Web.Launcher;
@ -36,7 +35,6 @@ sealed class WebConfigurator : WebLauncher.IConfigurator {
services.AddSingleton<MinecraftVersions>();
services.AddSingleton<MessageToServerListenerFactory>();
services.AddScoped<IdentityLookup>();
services.AddScoped<AuditLog>();
}

View File

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace Phantom.Utils.Collections;
@ -11,4 +12,24 @@ public static class EnumerableExtensions {
}
}
}
public static async Task<ImmutableArray<TSource>> ToImmutableArrayAsync<TSource>(this IAsyncEnumerable<TSource> source, CancellationToken cancellationToken = default) {
var builder = ImmutableArray.CreateBuilder<TSource>();
await foreach (var element in source.WithCancellation(cancellationToken)) {
builder.Add(element);
}
return builder.ToImmutable();
}
public static async Task<ImmutableHashSet<TSource>> ToImmutableSetAsync<TSource>(this IAsyncEnumerable<TSource> source, CancellationToken cancellationToken = default) {
var builder = ImmutableHashSet.CreateBuilder<TSource>();
await foreach (var element in source.WithCancellation(cancellationToken)) {
builder.Add(element);
}
return builder.ToImmutable();
}
}

View File

@ -0,0 +1,37 @@
namespace Phantom.Utils.Tasks;
public abstract record Result<TValue, TError> {
private Result() {}
public sealed record Ok(TValue Value) : Result<TValue, TError>;
public sealed record Fail(TError Error) : Result<TValue, TError>;
}
public abstract record Result<TError> {
private Result() {}
public sealed record Ok : Result<TError> {
internal static Ok Instance { get; } = new ();
}
public sealed record Fail(TError Error) : Result<TError>;
}
public static class Result {
public static Result<TError> Ok<TError>() {
return Result<TError>.Ok.Instance;
}
public static Result<TError> Fail<TError>(TError error) {
return new Result<TError>.Fail(error);
}
public static Result<TValue, TError> Ok<TValue, TError>(TValue value) {
return new Result<TValue, TError>.Ok(value);
}
public static Result<TValue, TError> Fail<TValue, TError>(TError error) {
return new Result<TValue, TError>.Fail(error);
}
}