From b2984fcf1ad9e057d3360e48de3869d7e9920307 Mon Sep 17 00:00:00 2001 From: Jonas <77726472+kobolol@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:57:49 +0200 Subject: [PATCH] Replace IsAdmin with role-based admin Switch user admin handling from an AppUser boolean to ASP.NET Identity roles. Removed AppUser.IsAdmin and related configuration/model entries; added migration ReplaceIsAdminWithRoles to copy Users.IsAdmin=true into a persistent admin role and drop the IsAdmin column. CurrentUserResponse now exposes roles (string[]), AuthController returns ordered roles from UserManager, and IdentitySeedService now ensures the admin role exists and assigns/creates an initial admin user in that role. Program.cs registers an Admin-only policy (PolicyNames/RoleNames), adjusts cookie auth events to return 401/403 for API requests, and wires up authorization. Frontend updated to use roles: authSession normalizes roles, adds hasRole and ROLE_ADMIN, router and layout support meta.requiredRoles, and new Forbidden and AdminUsers pages/route are added. codexInfo.md updated to reflect the migration to role-based auth. --- API/Contracts/Auth/CurrentUserResponse.cs | 2 +- API/Controllers/Auth/AppUserController.cs | 10 +- API/Controllers/Auth/AuthController.cs | 6 +- .../Configurations/AppUserConfiguration.cs | 1 - ...174609_ReplaceIsAdminWithRoles.Designer.cs | 288 ++++++++++++++++++ .../20260420174609_ReplaceIsAdminWithRoles.cs | 80 +++++ .../ApplicationDbContextModelSnapshot.cs | 3 - API/Models/AppUser.cs | 1 - API/Program.cs | 40 +++ API/Security/PolicyNames.cs | 7 + API/Security/RoleNames.cs | 7 + API/Services/IdentitySeedService.cs | 73 +++-- GUI/src/Layout.vue | 13 + GUI/src/plugins/routesLayout.ts | 36 +++ GUI/src/router/index.ts | 14 +- GUI/src/routes/Forbidden.vue | 83 +++++ GUI/src/routes/admin/AdminUsers.vue | 155 ++++++++++ GUI/src/services/authSession.ts | 25 +- codexInfo.md | 8 +- 19 files changed, 813 insertions(+), 39 deletions(-) create mode 100644 API/Migrations/20260420174609_ReplaceIsAdminWithRoles.Designer.cs create mode 100644 API/Migrations/20260420174609_ReplaceIsAdminWithRoles.cs create mode 100644 API/Security/PolicyNames.cs create mode 100644 API/Security/RoleNames.cs create mode 100644 GUI/src/routes/Forbidden.vue create mode 100644 GUI/src/routes/admin/AdminUsers.vue diff --git a/API/Contracts/Auth/CurrentUserResponse.cs b/API/Contracts/Auth/CurrentUserResponse.cs index b8c04a9..0e14fb3 100644 --- a/API/Contracts/Auth/CurrentUserResponse.cs +++ b/API/Contracts/Auth/CurrentUserResponse.cs @@ -4,7 +4,7 @@ { public Guid Id { get; set; } public string UserName { get; set; } = string.Empty; - public bool IsAdmin { get; set; } + public List Roles { get; set; } = new(); public bool IsActive { get; set; } public bool MustChangePassword { get; set; } } diff --git a/API/Controllers/Auth/AppUserController.cs b/API/Controllers/Auth/AppUserController.cs index df8f95b..3d18dc7 100644 --- a/API/Controllers/Auth/AppUserController.cs +++ b/API/Controllers/Auth/AppUserController.cs @@ -1,5 +1,5 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; +using API.Security; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace API.Controllers.Auth @@ -9,10 +9,10 @@ namespace API.Controllers.Auth public class AppUserController : ControllerBase { [HttpGet] - [Authorize] - public async Task GetAppUsers() + [Authorize(Policy = PolicyNames.AdminOnly)] + public IActionResult GetAppUsers() { - return Ok(); + return Ok(new { message = "Adminzugriff bestätigt." }); } } } diff --git a/API/Controllers/Auth/AuthController.cs b/API/Controllers/Auth/AuthController.cs index 8884b47..696c92a 100644 --- a/API/Controllers/Auth/AuthController.cs +++ b/API/Controllers/Auth/AuthController.cs @@ -58,11 +58,13 @@ namespace API.Controllers.Auth if (user is null) return Unauthorized(); + var roles = await userManager.GetRolesAsync(user); + return Ok(new CurrentUserResponse { Id = user.Id, UserName = user.UserName ?? string.Empty, - IsAdmin = user.IsAdmin, + Roles = roles.OrderBy(x => x).ToList(), IsActive = user.IsActive, MustChangePassword = user.MustChangePassword }); @@ -114,4 +116,4 @@ namespace API.Controllers.Auth return Ok(new { message = "Passwort geändert. Du wurdest auf allen Geräten abgemeldet." }); } } -} \ No newline at end of file +} diff --git a/API/Database/Configurations/AppUserConfiguration.cs b/API/Database/Configurations/AppUserConfiguration.cs index 04c1462..8b2e419 100644 --- a/API/Database/Configurations/AppUserConfiguration.cs +++ b/API/Database/Configurations/AppUserConfiguration.cs @@ -12,7 +12,6 @@ namespace API.Database.Configurations builder.Property(x => x.CreatedAt).IsRequired(); builder.Property(x => x.UpdatedAt).IsRequired(); - builder.Property(x => x.IsAdmin).IsRequired(); builder.Property(x => x.IsActive).IsRequired(); builder.Property(x => x.MustChangePassword).IsRequired(); } diff --git a/API/Migrations/20260420174609_ReplaceIsAdminWithRoles.Designer.cs b/API/Migrations/20260420174609_ReplaceIsAdminWithRoles.Designer.cs new file mode 100644 index 0000000..97d3b23 --- /dev/null +++ b/API/Migrations/20260420174609_ReplaceIsAdminWithRoles.Designer.cs @@ -0,0 +1,288 @@ +// +using System; +using API.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace API.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260420174609_ReplaceIsAdminWithRoles")] + partial class ReplaceIsAdminWithRoles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("API.Models.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Models.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Migrations/20260420174609_ReplaceIsAdminWithRoles.cs b/API/Migrations/20260420174609_ReplaceIsAdminWithRoles.cs new file mode 100644 index 0000000..3f21bf8 --- /dev/null +++ b/API/Migrations/20260420174609_ReplaceIsAdminWithRoles.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Migrations +{ + /// + public partial class ReplaceIsAdminWithRoles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DO $$ + DECLARE + admin_role_id uuid; + BEGIN + SELECT "Id" INTO admin_role_id + FROM "Roles" + WHERE "NormalizedName" = 'ADMIN' + LIMIT 1; + + IF admin_role_id IS NULL THEN + admin_role_id := '2b34c0e2-9d53-4d79-bb85-bff03ce9e1ee'; + + INSERT INTO "Roles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp") + VALUES (admin_role_id, 'admin', 'ADMIN', NULL) + ON CONFLICT ("Id") DO NOTHING; + + SELECT "Id" INTO admin_role_id + FROM "Roles" + WHERE "NormalizedName" = 'ADMIN' + LIMIT 1; + END IF; + + INSERT INTO "UserRoles" ("UserId", "RoleId") + SELECT u."Id", admin_role_id + FROM "Users" u + WHERE u."IsAdmin" = TRUE + AND admin_role_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM "UserRoles" ur + WHERE ur."UserId" = u."Id" + AND ur."RoleId" = admin_role_id + ); + END $$; + """); + + migrationBuilder.DropColumn( + name: "IsAdmin", + table: "Users"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAdmin", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.Sql( + """ + UPDATE "Users" u + SET "IsAdmin" = TRUE + WHERE EXISTS ( + SELECT 1 + FROM "UserRoles" ur + INNER JOIN "Roles" r ON r."Id" = ur."RoleId" + WHERE ur."UserId" = u."Id" + AND r."NormalizedName" = 'ADMIN' + ); + """); + } + } +} diff --git a/API/Migrations/ApplicationDbContextModelSnapshot.cs b/API/Migrations/ApplicationDbContextModelSnapshot.cs index ad7e110..a505e43 100644 --- a/API/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/API/Migrations/ApplicationDbContextModelSnapshot.cs @@ -48,9 +48,6 @@ namespace API.Migrations b.Property("IsActive") .HasColumnType("boolean"); - b.Property("IsAdmin") - .HasColumnType("boolean"); - b.Property("LockoutEnabled") .HasColumnType("boolean"); diff --git a/API/Models/AppUser.cs b/API/Models/AppUser.cs index 3bf79b5..f7e5ddd 100644 --- a/API/Models/AppUser.cs +++ b/API/Models/AppUser.cs @@ -4,7 +4,6 @@ namespace API.Models { public class AppUser : IdentityUser { - public bool IsAdmin { get; set; } = false; public bool IsActive { get; set; } = true; public bool MustChangePassword { get; set; } = false; diff --git a/API/Program.cs b/API/Program.cs index b3bcb35..1c61a61 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,6 +1,8 @@ using API.Database; using API.Models; +using API.Security; using API.Services; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.HttpLogging; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -50,6 +52,13 @@ builder.Services }) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy(PolicyNames.AdminOnly, policy => + { + policy.RequireRole(RoleNames.Admin); + }); +}); builder.Services.ConfigureApplicationCookie(options => { @@ -59,6 +68,31 @@ builder.Services.ConfigureApplicationCookie(options => options.AccessDeniedPath = "/auth/forbidden"; options.SlidingExpiration = true; options.Cookie.HttpOnly = true; + options.Events = new CookieAuthenticationEvents + { + OnRedirectToLogin = context => + { + if (IsApiRequest(context.Request)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }, + OnRedirectToAccessDenied = context => + { + if (IsApiRequest(context.Request)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + } + }; }); builder.Services.AddScoped(); @@ -112,3 +146,9 @@ app.MapFallback(async context => }); app.Run(); + +static bool IsApiRequest(HttpRequest request) +{ + return request.Path.StartsWithSegments("/api") + || request.Path.StartsWithSegments("/auth"); +} diff --git a/API/Security/PolicyNames.cs b/API/Security/PolicyNames.cs new file mode 100644 index 0000000..e8fbd12 --- /dev/null +++ b/API/Security/PolicyNames.cs @@ -0,0 +1,7 @@ +namespace API.Security +{ + public static class PolicyNames + { + public const string AdminOnly = "admin-only"; + } +} diff --git a/API/Security/RoleNames.cs b/API/Security/RoleNames.cs new file mode 100644 index 0000000..2ba9adc --- /dev/null +++ b/API/Security/RoleNames.cs @@ -0,0 +1,7 @@ +namespace API.Security +{ + public static class RoleNames + { + public const string Admin = "admin"; + } +} diff --git a/API/Services/IdentitySeedService.cs b/API/Services/IdentitySeedService.cs index e69b52a..a065796 100644 --- a/API/Services/IdentitySeedService.cs +++ b/API/Services/IdentitySeedService.cs @@ -1,21 +1,23 @@ using API.Models; +using API.Security; using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; namespace API.Services { public class IdentitySeedService( UserManager userManager, + RoleManager> roleManager, IConfiguration configuration, ILogger logger) { public async Task SeedAsync() { - var hasAdmin = await userManager.Users.AnyAsync(x => x.IsAdmin); + await EnsureRoleExistsAsync(RoleNames.Admin); - if (hasAdmin) + var adminUsers = await userManager.GetUsersInRoleAsync(RoleNames.Admin); + if (adminUsers.Count > 0) { - logger.LogDebug("Admin-Seed übersprungen: Es existiert bereits ein Admin-Account."); + logger.LogDebug("Admin-Seed übersprungen: Es existiert bereits ein Admin über Rollen."); return; } @@ -23,30 +25,63 @@ namespace API.Services var adminPassword = configuration["SeedAdmin:Password"] ?? "HoardPassword"; var adminEmail = configuration["SeedAdmin:Email"]; - var admin = new AppUser + var admin = await userManager.FindByNameAsync(adminUserName); + if (admin is null) { - UserName = adminUserName, - Email = string.IsNullOrWhiteSpace(adminEmail) ? null : adminEmail, - IsAdmin = true, - IsActive = true, - MustChangePassword = true, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow - }; + admin = new AppUser + { + UserName = adminUserName, + Email = string.IsNullOrWhiteSpace(adminEmail) ? null : adminEmail, + IsActive = true, + MustChangePassword = true, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; - var result = await userManager.CreateAsync(admin, adminPassword); + var createResult = await userManager.CreateAsync(admin, adminPassword); + if (!createResult.Succeeded) + { + var createErrors = string.Join(", ", createResult.Errors.Select(x => x.Description)); + logger.LogError("Admin-Seed fehlgeschlagen: {Errors}", createErrors); + throw new InvalidOperationException($"Admin-Seed fehlgeschlagen: {createErrors}"); + } + } - if (!result.Succeeded) + if (!await userManager.IsInRoleAsync(admin, RoleNames.Admin)) { - var errors = string.Join(", ", result.Errors.Select(x => x.Description)); - logger.LogError("Admin-Seed fehlgeschlagen: {Errors}", errors); - throw new InvalidOperationException($"Admin-Seed fehlgeschlagen: {errors}"); + var roleResult = await userManager.AddToRoleAsync(admin, RoleNames.Admin); + if (!roleResult.Succeeded) + { + var roleErrors = string.Join(", ", roleResult.Errors.Select(x => x.Description)); + logger.LogError("Admin-Rollenzuweisung fehlgeschlagen: {Errors}", roleErrors); + throw new InvalidOperationException($"Admin-Rollenzuweisung fehlgeschlagen: {roleErrors}"); + } } logger.LogInformation( - "Admin-Account wurde geseedet (UserName: {UserName}, Email: {Email}).", + "Admin-Account wurde geseedet bzw. als Rolle zugewiesen (UserName: {UserName}, Email: {Email}).", admin.UserName, admin.Email ?? "(keine)"); } + + private async Task EnsureRoleExistsAsync(string roleName) + { + if (await roleManager.RoleExistsAsync(roleName)) + { + return; + } + + var createRoleResult = await roleManager.CreateAsync(new IdentityRole + { + Name = roleName + }); + + if (!createRoleResult.Succeeded) + { + var roleErrors = string.Join(", ", createRoleResult.Errors.Select(x => x.Description)); + logger.LogError("Rolle {RoleName} konnte nicht erstellt werden: {Errors}", roleName, roleErrors); + throw new InvalidOperationException($"Rolle {roleName} konnte nicht erstellt werden: {roleErrors}"); + } + } } } diff --git a/GUI/src/Layout.vue b/GUI/src/Layout.vue index c6d9978..7e01cad 100644 --- a/GUI/src/Layout.vue +++ b/GUI/src/Layout.vue @@ -9,6 +9,7 @@ import { Visibility, routes } from '@/plugins/routesLayout' import { AuthRequestError, fetchCurrentUser, + hasRole, logout, type CurrentUser, } from '@/services/authSession' @@ -75,6 +76,18 @@ const sidebarRoutes = computed(() => return currentUser.value === null } + if (item.visible === Visibility.Authorized) { + if (!currentUser.value) { + return false + } + + if (!item.requiredRoles || item.requiredRoles.length === 0) { + return true + } + + return item.requiredRoles.every((role) => hasRole(currentUser.value, role)) + } + if (item.visible !== Visibility.Route) { return false } diff --git a/GUI/src/plugins/routesLayout.ts b/GUI/src/plugins/routesLayout.ts index 6f88d31..06bd221 100644 --- a/GUI/src/plugins/routesLayout.ts +++ b/GUI/src/plugins/routesLayout.ts @@ -3,8 +3,11 @@ import type { RouteRecordRaw } from 'vue-router' import Home from '@/routes/Home.vue' import Dashboard from '@/routes/dashboard/Dashboard.vue' import NotFound from '@/routes/404NotFound.vue' +import Forbidden from '@/routes/Forbidden.vue' import Login from '@/routes/authentication/Login.vue' +import AdminUsers from '@/routes/admin/AdminUsers.vue' import Impressum from '@/routes/Impressum.vue' +import { ROLE_ADMIN } from '@/services/authSession' export enum Visibility { Hidden, @@ -24,6 +27,7 @@ export interface LayoutRoute { disableFooter?: boolean visible: Visibility visibilityRoute?: string | string[] + requiredRoles?: string[] meta?: RouteRecordRaw } @@ -65,6 +69,23 @@ export const routes: LayoutRoute[] = [ }, }, }, + { + path: '/admin/users', + name: 'Benutzer', + description: 'Adminbereich für Benutzerverwaltung', + icon: 'mdi-shield-account-outline', + visible: Visibility.Authorized, + requiredRoles: [ROLE_ADMIN], + meta: { + name: 'AdminUsers', + path: '/admin/users', + component: AdminUsers, + meta: { + requiresAuth: true, + requiredRoles: [ROLE_ADMIN], + }, + }, + }, { path: '/login', name: 'Login', @@ -92,6 +113,21 @@ export const routes: LayoutRoute[] = [ component: Impressum, }, }, + { + path: '/forbidden', + name: 'Kein Zugriff', + description: 'Du hast keine Berechtigung für diese Seite', + icon: 'mdi-alert-circle-outline', + visible: Visibility.Hidden, + meta: { + path: '/forbidden', + name: 'Forbidden', + component: Forbidden, + meta: { + requiresAuth: true, + }, + }, + }, { path: '/notFound', name: 'Nicht gefunden', diff --git a/GUI/src/router/index.ts b/GUI/src/router/index.ts index acc1519..cfe8393 100644 --- a/GUI/src/router/index.ts +++ b/GUI/src/router/index.ts @@ -1,6 +1,6 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { routes } from '@/plugins/routesLayout' -import { fetchCurrentUser } from '@/services/authSession' +import { fetchCurrentUser, hasRole } from '@/services/authSession' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -10,8 +10,11 @@ const router = createRouter({ router.beforeEach(async (to) => { const requiresAuth = to.meta.requiresAuth === true const guestOnly = to.meta.guestOnly === true + const requiredRoles = Array.isArray(to.meta.requiredRoles) + ? to.meta.requiredRoles.filter((role): role is string => typeof role === 'string') + : [] - if (!requiresAuth && !guestOnly) { + if (!requiresAuth && !guestOnly && requiredRoles.length === 0) { return true } @@ -45,6 +48,13 @@ router.beforeEach(async (to) => { } } + if (requiredRoles.length > 0 && !requiredRoles.every((role) => hasRole(currentUser, role))) { + return { + name: 'Forbidden', + replace: true, + } + } + if (guestOnly && isAuthenticated) { return { name: 'Dashboard', diff --git a/GUI/src/routes/Forbidden.vue b/GUI/src/routes/Forbidden.vue new file mode 100644 index 0000000..408df39 --- /dev/null +++ b/GUI/src/routes/Forbidden.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/GUI/src/routes/admin/AdminUsers.vue b/GUI/src/routes/admin/AdminUsers.vue new file mode 100644 index 0000000..f20ad01 --- /dev/null +++ b/GUI/src/routes/admin/AdminUsers.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/GUI/src/services/authSession.ts b/GUI/src/services/authSession.ts index 9c5b161..972ceb3 100644 --- a/GUI/src/services/authSession.ts +++ b/GUI/src/services/authSession.ts @@ -1,11 +1,13 @@ export interface CurrentUser { id: string userName: string - isAdmin: boolean + roles: string[] isActive: boolean mustChangePassword: boolean } +export const ROLE_ADMIN = 'admin' + interface ApiMessageResponse { message?: unknown } @@ -38,21 +40,38 @@ function normalizeCurrentUser(value: unknown): CurrentUser | null { return null } - const { id, userName, isAdmin, isActive, mustChangePassword } = value + const { id, userName, roles, isActive, mustChangePassword } = value if (typeof id !== 'string' || typeof userName !== 'string') { return null } + const normalizedRoles = Array.isArray(roles) + ? roles.filter((role): role is string => typeof role === 'string').map((role) => role.trim()).filter((role) => role.length > 0) + : [] + return { id, userName, - isAdmin: Boolean(isAdmin), + roles: normalizedRoles, isActive: Boolean(isActive), mustChangePassword: Boolean(mustChangePassword), } } +export function hasRole(user: CurrentUser | null | undefined, role: string): boolean { + if (!user) { + return false + } + + const normalizedRole = role.trim().toLowerCase() + if (normalizedRole.length === 0) { + return false + } + + return user.roles.some((userRole) => userRole.toLowerCase() === normalizedRole) +} + function isUnauthenticatedResponse(response: Response) { if (response.status === 401 || response.status === 403) { return true diff --git a/codexInfo.md b/codexInfo.md index e0b7a1f..fcc1109 100644 --- a/codexInfo.md +++ b/codexInfo.md @@ -91,11 +91,11 @@ Ich baue alleine neben meiner Ausbildung eine einfache self-hosted Web-App für - Swagger/OpenAPI ist im Backend nur im Development-Modus aktiv (`/swagger`). - Frontend-Build (`npm run build` im `GUI`-Projekt) schreibt direkt nach `API/wwwroot`; das Backend liefert die SPA und statische Assets aus. - Backend nutzt jetzt PostgreSQL über `ConnectionStrings:Postgres` mit EF Core (`ApplicationDbContext`) und führt Migrationen beim Start automatisch aus. -- Backend nutzt ASP.NET Identity mit `AppUser` (Guid-Key, `IsAdmin`, `IsActive`, `MustChangePassword`, `CreatedAt`, `UpdatedAt`) über PostgreSQL. +- Backend nutzt ASP.NET Identity mit `AppUser` (Guid-Key, `IsActive`, `MustChangePassword`, `CreatedAt`, `UpdatedAt`) über PostgreSQL; Admin-Rechte laufen über Rollen (`admin`) statt User-Flag. - `ApplicationDbContext` basiert auf `IdentityDbContext`; Identity-Tabellen sind auf `Users`, `Roles`, `UserRoles`, `UserClaims`, `UserLogins`, `RoleClaims` und `UserTokens` gemappt. - Migration `InitIdentity` (`API/Migrations/20260418192723_InitIdentity.cs`) erstellt das Identity-Schema und wird beim Start automatisch angewendet. - Temporäre Test-Entity und Test-CRUD-Endpunkt (`api/test-items`) wurden wieder entfernt. -- Nach den Migrationen wird per `IdentitySeedService` ein initialer Admin angelegt, falls noch kein Admin existiert. +- Nach den Migrationen wird per `IdentitySeedService` die Rolle `admin` sichergestellt und einem initialen Admin-Account zugewiesen, falls noch kein Admin in dieser Rolle existiert. - Für lokale Entwicklung liegt unter `API/Dev/docker-compose.yml` ein Stack mit PostgreSQL (`localhost:5432`) und pgAdmin (`localhost:5050`). - API lädt optional `API/appsettings.custom.json`; wenn vorhanden, überschreibt sie Werte aus `appsettings.json`. - `API/appsettings.custom.json` ist in `.gitignore` hinterlegt, damit lokale Konfigurationswerte nicht versehentlich committed werden. @@ -170,3 +170,7 @@ Ich baue alleine neben meiner Ausbildung eine einfache self-hosted Web-App für - `GUI/src/Layout.vue` Brand-Navigation nachgeschärft: `navigateToBrandTarget()` löst bei Klick zuerst die Session via `fetchCurrentUser()` auf (falls lokal noch `null`) und verhindert dadurch Fehlnavigation auf `Welcome`; Mobile-Account-Menütext auf `Zum Dashboard` vereinheitlicht. - Neuer globaler Skill `codexinfo-komprimieren` unter `C:/Users/famil/.codex/skills/codexinfo-komprimieren` erstellt; er liest `codexInfo.md`, verdichtet `Änderungen durch Codex` auf drei Kernzeilen und enthält ein Hilfsskript zum robusten Abschnitts-Update. - `GUI/src/routes/404NotFound.vue` erweitert: Seite ermittelt beim Laden den Login-Status über `fetchCurrentUser()`, setzt CTA/Icon dynamisch auf `Zum Dashboard` oder `Zur Startseite` und leitet nach kurzer Verzögerung automatisch auf das passende Ziel weiter. +- Rollenbasiertes Auth-Modell umgesetzt: `IsAdmin` aus `AppUser`/Konfiguration entfernt, neue Rolle-Konstante `admin` eingeführt und Admin-Prüfungen auf Policy/Rollen (`API/Security/*`, `Authorize(Policy = admin-only)`) umgestellt. +- Neue Migration `ReplaceIsAdminWithRoles` (`API/Migrations/20260420174609_ReplaceIsAdminWithRoles.cs`) ergänzt: migriert bestehende `Users.IsAdmin = true` idempotent in `UserRoles` (`admin`) und entfernt anschließend die Spalte `IsAdmin`. +- `GET /auth/me` liefert jetzt `roles: string[]` statt `isAdmin`; `GUI/src/services/authSession.ts` wurde auf Rollen normalisiert und um `hasRole()` ergänzt. +- Frontend-Autorisierung erweitert: Router unterstützt `meta.requiredRoles`, neue 403-Seite `GUI/src/routes/Forbidden.vue` und admin-spezifische Route `GUI/src/routes/admin/AdminUsers.vue` werden nur für Rolle `admin` zugänglich/angezeigt.