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

Add user and role permissions on web

This commit is contained in:
chylex 2022-10-21 20:11:55 +02:00
parent 8d3e4442d7
commit 0e6d506cb4
Signed by: chylex
GPG Key ID: 4DE42C8F19A80548
27 changed files with 1064 additions and 76 deletions

View File

@ -1,6 +1,7 @@
<Project>
<ItemGroup>
<PackageReference Update="Microsoft.AspNetCore.Components.Authorization" Version="7.0.0-rc.1.22427.2" />
<PackageReference Update="Microsoft.AspNetCore.Components.Web" Version="7.0.0-rc.1.22427.2" />
<PackageReference Update="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.0-rc.1.22427.2" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0-rc.1.22426.7" />

View File

@ -0,0 +1,466 @@
// <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("20221021125232_Permissions")]
partial class Permissions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.0-rc.1.22426.7")
.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")
.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.AuditEventEntity", 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<string>("UserId")
.HasColumnType("text");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AuditEvents", "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.RolePermissionEntity", b =>
{
b.Property<string>("RoleId")
.HasColumnType("text");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("RoleId", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("RolePermissions", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.UserPermissionEntity", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("UserId", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("UserPermissions", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
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();
});
modelBuilder.Entity("Phantom.Server.Database.Entities.AuditEventEntity", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", "User")
.WithMany()
.HasForeignKey("UserId");
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("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.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("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,108 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Phantom.Server.Database.Postgres.Migrations
{
/// <inheritdoc />
public partial class Permissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Permissions",
schema: "identity",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Permissions", x => x.Id);
});
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: "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.CreateIndex(
name: "IX_RolePermissions_PermissionId",
schema: "identity",
table: "RolePermissions",
column: "PermissionId");
migrationBuilder.CreateIndex(
name: "IX_UserPermissions_PermissionId",
schema: "identity",
table: "UserPermissions",
column: "PermissionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RolePermissions",
schema: "identity");
migrationBuilder.DropTable(
name: "UserPermissions",
schema: "identity");
migrationBuilder.DropTable(
name: "Permissions",
schema: "identity");
}
}
}

View File

@ -328,6 +328,46 @@ namespace Phantom.Server.Database.Postgres.Migrations
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.RolePermissionEntity", b =>
{
b.Property<string>("RoleId")
.HasColumnType("text");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("RoleId", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("RolePermissions", "identity");
});
modelBuilder.Entity("Phantom.Server.Database.Entities.UserPermissionEntity", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("UserId", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("UserPermissions", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -387,6 +427,36 @@ namespace Phantom.Server.Database.Postgres.Migrations
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("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.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("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}

View File

@ -14,6 +14,10 @@ namespace Phantom.Server.Database;
[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")]
public class ApplicationDbContext : IdentityDbContext {
public DbSet<PermissionEntity> Permissions { get; set; } = null!;
public DbSet<UserPermissionEntity> UserPermissions { get; set; } = null!;
public DbSet<RolePermissionEntity> RolePermissions { get; set; } = null!;
public DbSet<AgentEntity> Agents { get; set; } = null!;
public DbSet<InstanceEntity> Instances { get; set; } = null!;
public DbSet<AuditEventEntity> AuditEvents { get; set; } = null!;
@ -39,6 +43,18 @@ public class ApplicationDbContext : IdentityDbContext {
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<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.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.HasOne<PermissionEntity>().WithMany().HasForeignKey(static e => e.PermissionId).IsRequired().OnDelete(DeleteBehavior.Cascade);
});
}
protected override void ConfigureConventions(ModelConfigurationBuilder builder) {

View File

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Phantom.Server.Database.Entities;
[Table("Permissions", Schema = "identity")]
public class PermissionEntity {
[Key]
public string Id { get; set; }
public PermissionEntity(string id) {
Id = id;
}
}

View File

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Phantom.Server.Database.Entities;
[Table("RolePermissions", Schema = "identity")]
public class RolePermissionEntity {
public string RoleId { get; set; }
public string PermissionId { get; set; }
public RolePermissionEntity(string roleId, string permissionId) {
RoleId = roleId;
PermissionId = permissionId;
}
}

View File

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Phantom.Server.Database.Entities;
[Table("UserPermissions", Schema = "identity")]
public class UserPermissionEntity {
public string UserId { get; set; }
public string PermissionId { get; set; }
public UserPermissionEntity(string userId, string permissionId) {
UserId = userId;
PermissionId = permissionId;
}
}

View File

@ -18,7 +18,7 @@ public sealed partial class AuditLog {
}
public void AddUserLoggedOutEvent(ClaimsPrincipal user) {
var userId = GetUserId(user);
var userId = identityLookup.GetAuthenticatedUserId(user);
AddEvent(userId, AuditEventType.UserLoggedOut, userId ?? string.Empty);
}

View File

@ -1,9 +1,9 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore;
using Phantom.Server.Database;
using Phantom.Server.Database.Entities;
using Phantom.Server.Database.Enums;
using Phantom.Server.Services.Users;
using Phantom.Utils.Runtime;
namespace Phantom.Server.Services.Audit;
@ -11,23 +11,21 @@ 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, AuthenticationStateProvider authenticationStateProvider, TaskManager taskManager) {
public AuditLog(ServiceConfiguration serviceConfiguration, DatabaseProvider databaseProvider, IdentityLookup identityLookup, AuthenticationStateProvider authenticationStateProvider, TaskManager taskManager) {
this.cancellationToken = serviceConfiguration.CancellationToken;
this.databaseProvider = databaseProvider;
this.identityLookup = identityLookup;
this.authenticationStateProvider = authenticationStateProvider;
this.taskManager = taskManager;
}
private static string? GetUserId(ClaimsPrincipal user) {
return user.FindFirstValue(ClaimTypes.NameIdentifier);
}
private async Task<string?> GetCurrentUserId() {
private async Task<string?> GetCurrentAuthenticatedUserId() {
var authenticationState = await authenticationStateProvider.GetAuthenticationStateAsync();
return authenticationState.User.Identity?.IsAuthenticated == true ? GetUserId(authenticationState.User) : null;
return identityLookup.GetAuthenticatedUserId(authenticationState.User);
}
private async Task AddEventToDatabase(AuditEventEntity eventEntity) {
@ -42,7 +40,7 @@ public sealed partial class AuditLog {
}
private async Task AddEvent(AuditEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
AddEvent(await GetCurrentUserId(), eventType, subjectId, extra);
AddEvent(await GetCurrentAuthenticatedUserId(), eventType, subjectId, extra);
}
public async Task<AuditEvent[]> GetEvents(int count, CancellationToken cancellationToken) {

View File

@ -0,0 +1,16 @@
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,30 @@
using System.Collections.Immutable;
using Phantom.Server.Web.Identity.Data;
namespace Phantom.Server.Web.Identity.Authorization;
public sealed class IdentityPermissions {
internal static IdentityPermissions None { get; } = new ();
private readonly ImmutableHashSet<string> permissionIds;
internal IdentityPermissions(IQueryable<string> permissionIdsQuery) {
this.permissionIds = permissionIdsQuery.ToImmutableHashSet();
}
private IdentityPermissions() {
this.permissionIds = ImmutableHashSet<string>.Empty;
}
public bool Check(Permission? permission) {
while (permission != null) {
if (!permissionIds.Contains(permission.Id)) {
return false;
}
permission = permission.Parent;
}
return true;
}
}

View File

@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Authorization;
namespace Phantom.Server.Web.Identity.Authorization;
sealed class PermissionBasedPolicyHandler : AuthorizationHandler<PermissionBasedPolicyRequirement> {
private readonly PermissionManager permissionManager;
public PermissionBasedPolicyHandler(PermissionManager permissionManager) {
this.permissionManager = permissionManager;
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionBasedPolicyRequirement requirement) {
if (permissionManager.CheckPermission(context.User, requirement.Permission)) {
context.Succeed(requirement);
}
else {
context.Fail(new AuthorizationFailureReason(this, "Missing permission: " + requirement.Permission.Id));
}
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Phantom.Server.Web.Identity.Data;
namespace Phantom.Server.Web.Identity.Authorization;
sealed record PermissionBasedPolicyRequirement(Permission Permission) : IAuthorizationRequirement;

View File

@ -0,0 +1,44 @@
using System.Security.Claims;
using Phantom.Server.Database;
using Phantom.Server.Services.Users;
using Phantom.Server.Web.Identity.Data;
namespace Phantom.Server.Web.Identity.Authorization;
public sealed class PermissionManager {
private readonly ApplicationDbContext db;
private readonly IdentityLookup identityLookup;
private readonly Dictionary<string, IdentityPermissions> userIdsToPermissionIds = new ();
public PermissionManager(ApplicationDbContext db, IdentityLookup identityLookup) {
this.db = db;
this.identityLookup = identityLookup;
}
private IdentityPermissions FetchPermissions(string userId) {
var userPermissions = db.UserPermissions.Where(up => up.UserId == userId).Select(static up => up.PermissionId);
var rolePermissions = db.UserRoles.Where(ur => ur.UserId == userId).Join(db.RolePermissions, static ur => ur.RoleId, static rp => rp.RoleId, static (ur, rp) => rp.PermissionId);
return new IdentityPermissions(userPermissions.Union(rolePermissions));
}
private IdentityPermissions GetPermissionsForUserId(string? userId) {
if (userId == null) {
return IdentityPermissions.None;
}
if (userIdsToPermissionIds.TryGetValue(userId, out var userPermissions)) {
return userPermissions;
}
else {
return userIdsToPermissionIds[userId] = FetchPermissions(userId);
}
}
public IdentityPermissions GetPermissions(ClaimsPrincipal user) {
return GetPermissionsForUserId(identityLookup.GetAuthenticatedUserId(user));
}
public bool CheckPermission(ClaimsPrincipal user, Permission permission) {
return GetPermissions(user).Check(permission);
}
}

View File

@ -0,0 +1,21 @@
@using Microsoft.AspNetCore.Components.Authorization
@using Phantom.Server.Web.Identity.Data
@inject PermissionManager PermissionManager
<AuthorizeView>
<Authorized>
@if (PermissionManager.CheckPermission(context.User, Permission)) {
@ChildContent
}
</Authorized>
</AuthorizeView>
@code {
[Parameter, EditorRequired]
public Permission Permission { get; set; } = null!;
[Parameter]
public RenderFragment? ChildContent { get; set; }
}

View File

@ -0,0 +1,12 @@
namespace Phantom.Server.Web.Identity.Data;
public sealed record Permission(string Id, Permission? Parent) {
private static readonly List<Permission> AllPermissions = new ();
public static IEnumerable<Permission> All => AllPermissions;
private static Permission Register(string id, Permission? parent = null) {
var permission = new Permission(id, parent);
AllPermissions.Add(permission);
return permission;
}
}

View File

@ -0,0 +1,16 @@
using System.Collections.Immutable;
namespace Phantom.Server.Web.Identity.Data;
public sealed record Role(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);
AllRoles.Add(role);
return role;
}
public static readonly Role Administrator = Register("Administrator", Permission.All.ToImmutableArray());
}

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
@ -7,6 +7,11 @@
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" />
</ItemGroup>

View File

@ -0,0 +1,100 @@
using System.Collections.Immutable;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Phantom.Common.Logging;
using Phantom.Server.Database;
using Phantom.Server.Database.Entities;
using Phantom.Server.Web.Identity.Data;
using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Server.Web.Identity;
public sealed class PhantomIdentityConfigurator {
private static readonly ILogger Logger = PhantomLogger.Create<PhantomIdentityConfigurator>();
public static async Task MigrateDatabase(IServiceProvider serviceProvider) {
await using var scope = serviceProvider.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<PhantomIdentityConfigurator>().Initialize();
}
private readonly ApplicationDbContext db;
private readonly RoleManager<IdentityRole> roleManager;
public PhantomIdentityConfigurator(ApplicationDbContext db, RoleManager<IdentityRole> roleManager) {
this.db = db;
this.roleManager = roleManager;
}
private async Task Initialize() {
CreatePermissions();
await CreateDefaultRoles();
await AssignDefaultRolePermissions();
await db.SaveChangesAsync();
}
private void CreatePermissions() {
var existingPermissionIds = db.Permissions.Select(static p => p.Id).ToHashSet();
var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds);
if (!missingPermissionIds.IsEmpty) {
Logger.Information("Adding permissions: {Permissions}", string.Join(", ", missingPermissionIds));
foreach (var permissionId in missingPermissionIds) {
db.Permissions.Add(new PermissionEntity(permissionId));
}
}
}
private async Task CreateDefaultRoles() {
foreach (var role in Role.All) {
string name = role.Name;
if (await roleManager.RoleExistsAsync(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;
}
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..");
foreach (var role in Role.All) {
var roleEntity = await roleManager.FindByNameAsync(role.Name);
if (roleEntity == null) {
Logger.Fatal("Error assigning default role permissions, role {RoleName} not found.", role.Name);
throw StopProcedureException.Instance;
}
var existingPermissionIds = db.RolePermissions.Where(rp => rp.RoleId == roleEntity.Id).Select(static rp => rp.PermissionId).ToHashSet();
var missingPermissionIds = GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds);
if (!missingPermissionIds.IsEmpty) {
Logger.Information("Assigning default permission to role {RoleName}: {Permissions}", role.Name, string.Join(", ", missingPermissionIds));
foreach (var permissionId in missingPermissionIds) {
db.RolePermissions.Add(new RolePermissionEntity(roleEntity.Id, permissionId));
}
}
}
}
private static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, HashSet<string> existingPermissionIds) {
return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
}
}

View File

@ -1,18 +1,73 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Phantom.Server.Database;
using Phantom.Server.Web.Identity.Authentication;
using Phantom.Server.Web.Identity.Authorization;
using Phantom.Server.Web.Identity.Data;
namespace Phantom.Server.Web.Identity;
namespace Phantom.Server.Web.Identity;
public static class PhantomIdentityExtensions {
public static void AddPhantomIdentity<TUser>(this IServiceCollection services) where TUser : class {
public static void AddPhantomIdentity<TUser, TRole>(this IServiceCollection services) where TUser : class where TRole : class {
services.AddIdentity<TUser, TRole>(ConfigureIdentity).AddEntityFrameworkStores<ApplicationDbContext>();
services.ConfigureApplicationCookie(ConfigureIdentityCookie);
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
services.AddAuthorization(ConfigureAuthorization);
services.AddSingleton<PhantomLoginStore>();
services.AddScoped<PhantomLoginManager>();
services.AddScoped<PhantomIdentityConfigurator>();
services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>();
services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<TUser>>();
services.AddTransient<PermissionManager>();
}
public static void UsePhantomIdentity(this IApplicationBuilder application) {
application.UseAuthentication();
application.UseAuthorization();
application.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;
o.Cookie.SameSite = SameSiteMode.Lax;
o.ExpireTimeSpan = TimeSpan.FromDays(30);
o.SlidingExpiration = true;
o.LoginPath = "/login";
o.LogoutPath = "/logout";
o.AccessDeniedPath = "/login";
}
private static void ConfigureAuthorization(AuthorizationOptions o) {
foreach (var permission in Permission.All) {
o.AddPolicy(permission.Id, policy => policy.Requirements.Add(new PermissionBasedPolicyRequirement(permission)));
}
}
}

View File

@ -6,10 +6,14 @@
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@{
@if (context.User.Identity is not { IsAuthenticated: true }) {
var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri).TrimEnd('/');
Nav.NavigateTo("login" + QueryString.Create("return", returnUrl), forceLoad: true);
}
else {
<h1>Forbidden</h1>
<p role="alert">You do not have permission to visit this page.</p>
}
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />

View File

@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Phantom.Server.Database;
@ -25,22 +24,18 @@ public static class Launcher {
if (builder.Environment.IsEnvironment("Local")) {
builder.WebHost.UseStaticWebAssets();
}
configurator.ConfigureServices(builder.Services);
builder.Services.AddSingleton<IHostLifetime>(new NullLifetime());
builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath));
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.KeyFolderPath));
builder.Services.AddDbContextPool<ApplicationDbContext>(dbOptionsBuilder, poolSize: 64);
builder.Services.AddSingleton<DatabaseProvider>();
builder.Services.AddIdentity<IdentityUser, IdentityRole>(ConfigureIdentity).AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.ConfigureApplicationCookie(ConfigureIdentityCookie);
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
builder.Services.AddAuthorization();
builder.Services.AddPhantomIdentity<IdentityUser>();
builder.Services.AddPhantomIdentity<IdentityUser, IdentityRole>();
builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout");
builder.Services.AddServerSideBlazor();
@ -48,6 +43,7 @@ public static class Launcher {
var application = builder.Build();
await MigrateDatabase(config, application.Services.GetRequiredService<DatabaseProvider>());
await PhantomIdentityConfigurator.MigrateDatabase(application.Services);
await configurator.LoadFromDatabase(application.Services);
return application;
@ -65,8 +61,6 @@ public static class Launcher {
application.UseStaticFiles();
application.UseRouting();
application.UseAuthentication();
application.UseAuthorization();
application.UsePhantomIdentity();
application.MapControllers();
@ -87,36 +81,6 @@ public static class Launcher {
}
}
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;
o.Cookie.SameSite = SameSiteMode.Lax;
o.ExpireTimeSpan = TimeSpan.FromDays(30);
o.SlidingExpiration = true;
o.LoginPath = "/login";
o.LogoutPath = "/logout";
o.AccessDeniedPath = "/login";
}
private static async Task MigrateDatabase(Configuration config, DatabaseProvider databaseProvider) {
var logger = config.Logger;
@ -136,7 +100,7 @@ public static class Launcher {
}
public interface IConfigurator {
void ConfigureServices( IServiceCollection services);
void ConfigureServices(IServiceCollection services);
Task LoadFromDatabase(IServiceProvider serviceProvider);
}
}

View File

@ -10,7 +10,6 @@
@inject ServiceConfiguration ServiceConfiguration
@inject PhantomLoginManager LoginManager
@inject UserManager<IdentityUser> UserManager
@inject RoleManager<IdentityRole> RoleManager
@inject AuditLog AuditLog
<h1>Administrator Setup</h1>
@ -68,11 +67,6 @@
return;
}
var createRolesResult = await CreateDefaultRoles();
if (!createRolesResult.Succeeded) {
form.SubmitModel.StopSubmitting(GetErrors(createRolesResult));
}
var createUserResult = await CreateOrUpdateAdministrator();
if (!createUserResult.Succeeded) {
form.SubmitModel.StopSubmitting(GetErrors(createUserResult));
@ -96,14 +90,6 @@
return CryptographicOperations.FixedTimeEquals(formTokenBytes, ServiceConfiguration.AdministratorToken);
}
private async Task<IdentityResult> CreateDefaultRoles() {
if (!await RoleManager.RoleExistsAsync("Administrator")) {
return await RoleManager.CreateAsync(new IdentityRole("Administrator"));
}
return IdentityResult.Success;
}
private async Task<IdentityResult> CreateOrUpdateAdministrator() {
var existingUser = await UserManager.FindByNameAsync(form.Username);
return existingUser == null ? await CreateAdministrator() : await UpdateAdministrator(existingUser);
@ -118,7 +104,7 @@
await AuditLog.AddAdministratorUserCreatedEvent(newUser);
var addToRoleResult = await UserManager.AddToRoleAsync(newUser, "Administrator");
var addToRoleResult = await UserManager.AddToRoleAsync(newUser, Role.Administrator.Name);
return addToRoleResult;
}

View File

@ -11,6 +11,8 @@
@using Phantom.Server.Web.Components.Graphics
@using Phantom.Server.Web.Components.Tables
@using Phantom.Server.Web.Identity
@using Phantom.Server.Web.Identity.Authorization
@using Phantom.Server.Web.Identity.Data
@using Phantom.Server.Web.Layout
@using Phantom.Server.Web.Shared
@attribute [Authorize]

View File

@ -74,6 +74,8 @@ try {
);
} catch (OperationCanceledException) {
// Ignore.
} catch (StopProcedureException) {
// Ignore.
} finally {
cancellationTokenSource.Cancel();

View File

@ -6,6 +6,7 @@ using Phantom.Server.Services.Agents;
using Phantom.Server.Services.Audit;
using Phantom.Server.Services.Instances;
using Phantom.Server.Services.Rpc;
using Phantom.Server.Services.Users;
using Phantom.Utils.Runtime;
using WebLauncher = Phantom.Server.Web.Launcher;
@ -33,7 +34,8 @@ sealed class WebConfigurator : WebLauncher.IConfigurator {
services.AddSingleton<InstanceLogManager>();
services.AddSingleton<MinecraftVersions>();
services.AddSingleton<MessageToServerListenerFactory>();
services.AddScoped<IdentityLookup>();
services.AddScoped<AuditLog>();
}